uxid 0.2.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +139 -41
- data/lib/uxid/decoded.rb +53 -0
- data/lib/uxid/decoder.rb +107 -0
- data/lib/uxid/encoder.rb +86 -155
- data/lib/uxid/error.rb +35 -0
- data/lib/uxid/monotonic.rb +213 -0
- data/lib/uxid/options.rb +144 -0
- data/lib/uxid/version.rb +4 -1
- data/lib/uxid.rb +155 -61
- metadata +17 -29
- data/.github/workflows/build.yml +0 -72
- data/.gitignore +0 -43
- data/.rubocop.yml +0 -103
- data/.simplecov +0 -8
- data/Dangerfile +0 -5
- data/Gemfile +0 -15
- data/Gemfile.lock +0 -171
- data/Rakefile +0 -3
- data/bin/console +0 -7
- data/bin/setup +0 -8
- data/lib/tasks/bundler.rake +0 -4
- data/lib/tasks/check.rake +0 -2
- data/lib/tasks/linting.rake +0 -19
- data/lib/tasks/quality.rake +0 -12
- data/lib/tasks/test.rake +0 -8
- data/lib/uxid/model.rb +0 -44
- data/uxid.gemspec +0 -27
data/lib/uxid/encoder.rb
CHANGED
|
@@ -1,180 +1,111 @@
|
|
|
1
|
-
|
|
2
|
-
class Encoder
|
|
3
|
-
def self.encode model
|
|
4
|
-
new(model).encode
|
|
5
|
-
end
|
|
6
|
-
|
|
7
|
-
def initialize model
|
|
8
|
-
@model = model
|
|
9
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
10
2
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@model.entropy_encoded = public_send "encode_entropy_#{@model.rand_size}"
|
|
3
|
+
require "digest"
|
|
4
|
+
require "securerandom"
|
|
14
5
|
|
|
15
|
-
|
|
6
|
+
module UXID
|
|
7
|
+
# Builds a time body and its id from resolved options. Internal: the public
|
|
8
|
+
# entry point is UXID.generate, which documents every option.
|
|
9
|
+
module Encoder
|
|
10
|
+
LOWER = "0123456789abcdefghjkmnpqrstvwxyz"
|
|
11
|
+
UPPER = LOWER.upcase.freeze
|
|
12
|
+
|
|
13
|
+
COMPACT_BITS = 40
|
|
14
|
+
COMPACT_MASK = (2**COMPACT_BITS) - 1
|
|
15
|
+
MARKER = 31
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# @param request [Options::Resolved, Options::Deterministic]
|
|
20
|
+
# @return [String] the id
|
|
21
|
+
def generate(request)
|
|
22
|
+
body = request.is_a?(Options::Deterministic) ? deterministic_body(request) : body(request)
|
|
23
|
+
prefix = request.prefix
|
|
24
|
+
prefix.nil? || prefix.empty? ? body : "#{prefix}#{request.delimiter}#{body}"
|
|
16
25
|
end
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
# The marker, then the first count x 5 bits of the SHA-256 of
|
|
28
|
+
# prefix || 0x00 || from || 0x00 || count, as count characters. The
|
|
29
|
+
# prefix and count are in the hash so that the same string under another
|
|
30
|
+
# prefix or at another size gives an unrelated body; case is not.
|
|
31
|
+
def deterministic_body(request)
|
|
32
|
+
alphabet = request.upper ? UPPER : LOWER
|
|
33
|
+
alphabet[MARKER] + encode_groups(hash_bits(request), request.count, alphabet)
|
|
25
34
|
end
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
UXID::CROCKFORD_ENCODING[(b1&62)>>1] +
|
|
33
|
-
UXID::CROCKFORD_ENCODING[((b1&1)<<4)]
|
|
36
|
+
# The digest's first count x 5 bits, as an Integer.
|
|
37
|
+
def hash_bits(request)
|
|
38
|
+
input = [hashed_bytes(request.prefix.to_s), hashed_bytes(request.from), request.count]
|
|
39
|
+
digest = Digest::SHA256.digest(input.pack("a*xa*xC"))
|
|
40
|
+
digest.unpack1("H*").to_i(16) >> ((digest.bytesize * 8) - (request.count * 5))
|
|
34
41
|
end
|
|
35
42
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
UXID::CROCKFORD_ENCODING[((b0&7)<<2) | ((b1&192)>>6)] +
|
|
41
|
-
UXID::CROCKFORD_ENCODING[(b1&62)>>1] +
|
|
42
|
-
UXID::CROCKFORD_ENCODING[((b1&1)<<4) | ((b2&240)>>4)] +
|
|
43
|
-
UXID::CROCKFORD_ENCODING[((b2&15)<<1)]
|
|
43
|
+
def body(request)
|
|
44
|
+
alphabet = request.upper ? UPPER : LOWER
|
|
45
|
+
bytes = request.rand.nil? ? draw(request.byte_source, request.width) : fixed(request.rand, request.width)
|
|
46
|
+
encode_time(request.millis, request.compact_time, alphabet) + encode_rand(bytes, alphabet)
|
|
44
47
|
end
|
|
45
48
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
UXID::CROCKFORD_ENCODING[(b0&248)>>3] +
|
|
50
|
-
UXID::CROCKFORD_ENCODING[((b0&7)<<2) | ((b1&192)>>6)] +
|
|
51
|
-
UXID::CROCKFORD_ENCODING[(b1&62)>>1] +
|
|
52
|
-
UXID::CROCKFORD_ENCODING[((b1&1)<<4) | ((b2&240)>>4)] +
|
|
53
|
-
UXID::CROCKFORD_ENCODING[((b2&15)<<1) | ((b3&128)>>7)] +
|
|
54
|
-
UXID::CROCKFORD_ENCODING[(b3&124)>>2] +
|
|
55
|
-
UXID::CROCKFORD_ENCODING[((b3&3)<<3)]
|
|
56
|
-
end
|
|
49
|
+
def draw(byte_source, width)
|
|
50
|
+
bytes = byte_source.nil? ? SecureRandom.random_bytes(width) : byte_source.call(width)
|
|
51
|
+
return bytes.b if bytes.is_a?(String) && bytes.bytesize == width
|
|
57
52
|
|
|
58
|
-
|
|
59
|
-
b0, b1, b2, b3, b4 = @model.entropy_bytes
|
|
60
|
-
|
|
61
|
-
UXID::CROCKFORD_ENCODING[(b0&248)>>3] +
|
|
62
|
-
UXID::CROCKFORD_ENCODING[((b0&7)<<2) | ((b1&192)>>6)] +
|
|
63
|
-
UXID::CROCKFORD_ENCODING[(b1&62)>>1] +
|
|
64
|
-
UXID::CROCKFORD_ENCODING[((b1&1)<<4) | ((b2&240)>>4)] +
|
|
65
|
-
UXID::CROCKFORD_ENCODING[((b2&15)<<1) | ((b3&128)>>7)] +
|
|
66
|
-
UXID::CROCKFORD_ENCODING[(b3&124)>>2] +
|
|
67
|
-
UXID::CROCKFORD_ENCODING[((b3&3)<<3) | ((b4&224)>>5)] +
|
|
68
|
-
UXID::CROCKFORD_ENCODING[b4&31]
|
|
53
|
+
raise ArgumentError, "byte_source: must return a String of #{width} bytes, got: #{bytes.inspect}"
|
|
69
54
|
end
|
|
70
55
|
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
UXID::CROCKFORD_ENCODING[(b0&248)>>3] +
|
|
75
|
-
UXID::CROCKFORD_ENCODING[((b0&7)<<2) | ((b1&192)>>6)] +
|
|
76
|
-
UXID::CROCKFORD_ENCODING[(b1&62)>>1] +
|
|
77
|
-
UXID::CROCKFORD_ENCODING[((b1&1)<<4) | ((b2&240)>>4)] +
|
|
78
|
-
UXID::CROCKFORD_ENCODING[((b2&15)<<1) | ((b3&128)>>7)] +
|
|
79
|
-
UXID::CROCKFORD_ENCODING[(b3&124)>>2] +
|
|
80
|
-
UXID::CROCKFORD_ENCODING[((b3&3)<<3) | ((b4&224)>>5)] +
|
|
81
|
-
UXID::CROCKFORD_ENCODING[b4&31] +
|
|
82
|
-
UXID::CROCKFORD_ENCODING[(b5&248)>>3] +
|
|
83
|
-
UXID::CROCKFORD_ENCODING[((b5&7)<<2)]
|
|
84
|
-
end
|
|
56
|
+
def fixed(rand, width)
|
|
57
|
+
return rand.b if rand.bytesize == width
|
|
85
58
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
UXID::CROCKFORD_ENCODING[(b0&248)>>3] +
|
|
90
|
-
UXID::CROCKFORD_ENCODING[((b0&7)<<2) | ((b1&192)>>6)] +
|
|
91
|
-
UXID::CROCKFORD_ENCODING[(b1&62)>>1] +
|
|
92
|
-
UXID::CROCKFORD_ENCODING[((b1&1)<<4) | ((b2&240)>>4)] +
|
|
93
|
-
UXID::CROCKFORD_ENCODING[((b2&15)<<1) | ((b3&128)>>7)] +
|
|
94
|
-
UXID::CROCKFORD_ENCODING[(b3&124)>>2] +
|
|
95
|
-
UXID::CROCKFORD_ENCODING[((b3&3)<<3) | ((b4&224)>>5)] +
|
|
96
|
-
UXID::CROCKFORD_ENCODING[b4&31] +
|
|
97
|
-
UXID::CROCKFORD_ENCODING[(b5&248)>>3] +
|
|
98
|
-
UXID::CROCKFORD_ENCODING[((b5&7)<<2) | ((b6&192)>>6)] +
|
|
99
|
-
UXID::CROCKFORD_ENCODING[(b6&62)>>1] +
|
|
100
|
-
UXID::CROCKFORD_ENCODING[((b6&1)<<4)]
|
|
59
|
+
raise Error.new(:rand_width_mismatch,
|
|
60
|
+
"rand: must be exactly #{width} bytes for this call (the random field's width), " \
|
|
61
|
+
"got: #{rand.bytesize}")
|
|
101
62
|
end
|
|
102
63
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
UXID::CROCKFORD_ENCODING[(b6&62)>>1] +
|
|
117
|
-
UXID::CROCKFORD_ENCODING[((b6&1)<<4) | ((b7&240)>>4)] +
|
|
118
|
-
UXID::CROCKFORD_ENCODING[((b7&15)<<1)]
|
|
64
|
+
# The bytes hashed for a string. Valid text in an encoding other than
|
|
65
|
+
# UTF-8 is transcoded to UTF-8 first, so the same text gives the same
|
|
66
|
+
# bytes whatever its encoding. A binary (ASCII-8BIT) string, a string
|
|
67
|
+
# that is not valid in its own encoding, and one Ruby cannot transcode
|
|
68
|
+
# are hashed as their own bytes, as the wire format's "the input
|
|
69
|
+
# string's bytes" reads.
|
|
70
|
+
def hashed_bytes(string)
|
|
71
|
+
# A UTF-8 string, invalid or not, is returned unchanged; binary bytes
|
|
72
|
+
# above 0x7f, invalid bytes in another encoding and an encoding with
|
|
73
|
+
# no converter raise, and the string's own bytes are hashed.
|
|
74
|
+
string.encode(Encoding::UTF_8).b
|
|
75
|
+
rescue EncodingError
|
|
76
|
+
string.b
|
|
119
77
|
end
|
|
120
78
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
UXID::CROCKFORD_ENCODING[((b5&7)<<2) | ((b6&192)>>6)] +
|
|
134
|
-
UXID::CROCKFORD_ENCODING[(b6&62)>>1] +
|
|
135
|
-
UXID::CROCKFORD_ENCODING[((b6&1)<<4) | ((b7&240)>>4)] +
|
|
136
|
-
UXID::CROCKFORD_ENCODING[((b7&15)<<1) | ((b8&128)>>7)] +
|
|
137
|
-
UXID::CROCKFORD_ENCODING[(b8&124)>>2] +
|
|
138
|
-
UXID::CROCKFORD_ENCODING[((b8&3)<<3)]
|
|
79
|
+
# Standard time: 48 bits in 10 characters, the first holding 3 bits.
|
|
80
|
+
# Compact time: the low 40 bits in 8 characters.
|
|
81
|
+
def encode_time(millis, compact_time, alphabet)
|
|
82
|
+
return encode_groups(millis, 10, alphabet) unless compact_time
|
|
83
|
+
|
|
84
|
+
compact = millis & COMPACT_MASK
|
|
85
|
+
if compact >> (COMPACT_BITS - 5) == MARKER
|
|
86
|
+
raise Error.new(:compact_time_out_of_range,
|
|
87
|
+
"compact_time: cannot encode #{millis}: its first character would be the " \
|
|
88
|
+
"deterministic marker; use standard time")
|
|
89
|
+
end
|
|
90
|
+
encode_groups(compact, 8, alphabet)
|
|
139
91
|
end
|
|
140
92
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
UXID::CROCKFORD_ENCODING[(b3&124)>>2] +
|
|
150
|
-
UXID::CROCKFORD_ENCODING[((b3&3)<<3) | ((b4&224)>>5)] +
|
|
151
|
-
UXID::CROCKFORD_ENCODING[b4&31] +
|
|
152
|
-
UXID::CROCKFORD_ENCODING[(b5&248)>>3] +
|
|
153
|
-
UXID::CROCKFORD_ENCODING[((b5&7)<<2) | ((b6&192)>>6)] +
|
|
154
|
-
UXID::CROCKFORD_ENCODING[(b6&62)>>1] +
|
|
155
|
-
UXID::CROCKFORD_ENCODING[((b6&1)<<4) | ((b7&240)>>4)] +
|
|
156
|
-
UXID::CROCKFORD_ENCODING[((b7&15)<<1) | ((b8&128)>>7)] +
|
|
157
|
-
UXID::CROCKFORD_ENCODING[(b8&124)>>2] +
|
|
158
|
-
UXID::CROCKFORD_ENCODING[((b8&3)<<3) | ((b9&224)>>5)] +
|
|
159
|
-
UXID::CROCKFORD_ENCODING[b9&31]
|
|
93
|
+
# The random field, most significant bit first in 5-bit groups; the short
|
|
94
|
+
# group is the last one and is written as its raw value, never padded.
|
|
95
|
+
def encode_rand(bytes, alphabet)
|
|
96
|
+
bits = bytes.bytesize * 8
|
|
97
|
+
value = bytes.empty? ? 0 : bytes.unpack1("H*").to_i(16)
|
|
98
|
+
short = bits % 5
|
|
99
|
+
encoded = encode_groups(value >> short, bits / 5, alphabet)
|
|
100
|
+
short.zero? ? encoded : encoded << alphabet[value & ((1 << short) - 1)]
|
|
160
101
|
end
|
|
161
102
|
|
|
162
|
-
def
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
# Split these 5 bytes up into 10 characters using the encoding
|
|
167
|
-
# and join them together as a string
|
|
168
|
-
UXID::CROCKFORD_ENCODING[(b0&224)>>5] +
|
|
169
|
-
UXID::CROCKFORD_ENCODING[b0&31] +
|
|
170
|
-
UXID::CROCKFORD_ENCODING[(b1&248)>>3] +
|
|
171
|
-
UXID::CROCKFORD_ENCODING[((b1&7)<<2) | ((b2&192)>>6)] +
|
|
172
|
-
UXID::CROCKFORD_ENCODING[(b2&62)>>1] +
|
|
173
|
-
UXID::CROCKFORD_ENCODING[((b2&1)<<4) | ((b3&240)>>4)] +
|
|
174
|
-
UXID::CROCKFORD_ENCODING[((b3&15)<<1) | ((b4&128)>>7)] +
|
|
175
|
-
UXID::CROCKFORD_ENCODING[(b4&124)>>2] +
|
|
176
|
-
UXID::CROCKFORD_ENCODING[((b4&3)<<3) | ((b5&224)>>5)] +
|
|
177
|
-
UXID::CROCKFORD_ENCODING[b5&31]
|
|
103
|
+
def encode_groups(value, count, alphabet)
|
|
104
|
+
(count - 1).downto(0).each_with_object(String.new(capacity: count, encoding: Encoding::UTF_8)) do |index, out|
|
|
105
|
+
out << alphabet[(value >> (5 * index)) & 31]
|
|
106
|
+
end
|
|
178
107
|
end
|
|
179
108
|
end
|
|
109
|
+
|
|
110
|
+
private_constant :Encoder
|
|
180
111
|
end
|
data/lib/uxid/error.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UXID
|
|
4
|
+
# A generation request the wire format refuses: options that contradict each
|
|
5
|
+
# other or the format. {#code} names the refusal with the name the wire-format
|
|
6
|
+
# spec and its conformance corpus give it; the message is for people and may
|
|
7
|
+
# change.
|
|
8
|
+
#
|
|
9
|
+
# It is an ArgumentError, so a caller that rescues ArgumentError also catches
|
|
10
|
+
# the options UXID.generate refuses for other reasons (an unknown size, a
|
|
11
|
+
# time out of range), which carry no code.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# begin
|
|
15
|
+
# UXID.generate(prefix: "usr", rand_size: 11)
|
|
16
|
+
# rescue UXID::Error => e
|
|
17
|
+
# e.code # => :rand_size_too_large
|
|
18
|
+
# end
|
|
19
|
+
class Error < ArgumentError
|
|
20
|
+
# The refusal's name, one of :compact_time_out_of_range,
|
|
21
|
+
# :rand_width_mismatch, :rand_size_too_large, :rand_with_from,
|
|
22
|
+
# :from_with_monotonic and :rand_with_monotonic (the last two from
|
|
23
|
+
# UXID::Monotonic).
|
|
24
|
+
#
|
|
25
|
+
# @return [Symbol]
|
|
26
|
+
attr_reader :code
|
|
27
|
+
|
|
28
|
+
# @param code [Symbol] the refusal's name
|
|
29
|
+
# @param message [String] a description for people
|
|
30
|
+
def initialize(code, message)
|
|
31
|
+
@code = code
|
|
32
|
+
super(message)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UXID
|
|
4
|
+
# A monotonic generator: ids from one generator, for one prefix and random
|
|
5
|
+
# width, increase strictly, also within one millisecond and when the clock
|
|
6
|
+
# moves back. It follows the "Monotonic generation" rule of uxid-ex's
|
|
7
|
+
# wire-format spec. A monotonic id is an ordinary time-based id of its size;
|
|
8
|
+
# nothing in it says how it was made, and UXID.decode reads it as any other.
|
|
9
|
+
#
|
|
10
|
+
# The generator keeps, for each prefix and random width it has generated
|
|
11
|
+
# for, the last time T and the last random value R (the random field read
|
|
12
|
+
# as one big-endian unsigned integer of w bytes). For a call at the current
|
|
13
|
+
# time:
|
|
14
|
+
#
|
|
15
|
+
# 1. with no state for its prefix and width, or a time later than T, the
|
|
16
|
+
# random field is a fresh random value (a seed) and the id carries the
|
|
17
|
+
# current time;
|
|
18
|
+
# 2. with a time equal to or earlier than T, the id carries T and R + step,
|
|
19
|
+
# the step drawn uniformly from 1 to 2**(4w);
|
|
20
|
+
# 3. when R + step is 2**(8w) or more, the id carries T + 1 and a fresh
|
|
21
|
+
# seed instead.
|
|
22
|
+
#
|
|
23
|
+
# The step is not a plus-one counter: it spreads consecutive ids over a
|
|
24
|
+
# window of 2**(4w) values, which stops trivial enumeration but is not
|
|
25
|
+
# unpredictability. Low-entropy sizes stay low-entropy.
|
|
26
|
+
#
|
|
27
|
+
# The state belongs to the instance: two generators never share it, and
|
|
28
|
+
# nothing is kept at class level or globally. A Mutex inside the instance
|
|
29
|
+
# guards it, so one generator may be shared across threads and its ids
|
|
30
|
+
# still increase strictly for each prefix and width. Ids from different
|
|
31
|
+
# generators (one per thread, say) carry no such order between them.
|
|
32
|
+
#
|
|
33
|
+
# @example
|
|
34
|
+
# generator = UXID::Monotonic.new(prefix: "usr", size: :s)
|
|
35
|
+
# generator.generate(time: 1_700_000_000_000) # => "usr_01hf7yat00...."
|
|
36
|
+
# generator.generate(time: 1_700_000_000_000) # => a larger "usr_01hf7yat00...."
|
|
37
|
+
# generator.generate(prefix: "cus") # => "cus_01..." (its own state)
|
|
38
|
+
class Monotonic
|
|
39
|
+
# The options of UXID.generate a monotonic generator takes. time is given
|
|
40
|
+
# per call; from and rand are refused.
|
|
41
|
+
STREAM_OPTIONS = %i[prefix size rand_size compact_time case delimiter byte_source].freeze
|
|
42
|
+
private_constant :STREAM_OPTIONS
|
|
43
|
+
|
|
44
|
+
# The sizes whose standard time leaves no random field: compact time is
|
|
45
|
+
# their default here, so there is a field to step.
|
|
46
|
+
XS_NAMES = %w[xs xsmall].freeze
|
|
47
|
+
private_constant :XS_NAMES
|
|
48
|
+
|
|
49
|
+
State = Data.define(:millis, :value)
|
|
50
|
+
private_constant :State
|
|
51
|
+
|
|
52
|
+
# Makes a generator. Every option is one of UXID.generate's, with its
|
|
53
|
+
# meaning and its default there, and is the default of every call to
|
|
54
|
+
# {#generate}, which may give any of them again.
|
|
55
|
+
#
|
|
56
|
+
# One default differs: size :xs (or :xsmall) uses compact time unless
|
|
57
|
+
# :compact_time is given, because a standard xs has no random field to
|
|
58
|
+
# step. {#generate} refuses an xs with compact_time: false, and any other
|
|
59
|
+
# request whose random width is 0.
|
|
60
|
+
#
|
|
61
|
+
# @param prefix [String, nil] (nil) the prefix; nil and "" are both no
|
|
62
|
+
# prefix and share their state
|
|
63
|
+
# @param size [Symbol, String] (:xl) the size, as UXID.generate takes it
|
|
64
|
+
# @param draws [#seed, #step, nil] (nil) where the random values come
|
|
65
|
+
# from: any object whose seed(width) returns an Integer from 0 to
|
|
66
|
+
# 2**(8 * width) - 1, the fresh value of a width-byte field, and whose
|
|
67
|
+
# step(width) returns an Integer from 1 to 2**(4 * width), drawn
|
|
68
|
+
# uniformly. For replaying recorded draws, such as the conformance
|
|
69
|
+
# corpus's, never as a source of randomness. A value outside its range
|
|
70
|
+
# raises ArgumentError and the state is left as it was. nil draws from
|
|
71
|
+
# the byte source: a seed is width bytes read big-endian, a step is the
|
|
72
|
+
# low 4 * width bits of ceil(4 * width / 8) bytes plus 1, so neither is
|
|
73
|
+
# biased. When draws is given the byte source is not called
|
|
74
|
+
# @param options [Hash] the other options of UXID.generate except :time:
|
|
75
|
+
# :rand_size, :compact_time, :case, :delimiter and :byte_source
|
|
76
|
+
# @raise [UXID::Error] :from_with_monotonic for a :from, and
|
|
77
|
+
# :rand_with_monotonic for a :rand, that is not nil: a monotonic
|
|
78
|
+
# generator makes neither a deterministic id nor one with a fixed random
|
|
79
|
+
# field
|
|
80
|
+
# @raise [ArgumentError] for an unknown option, an option UXID.generate
|
|
81
|
+
# refuses, or a draws that does not respond to seed and step
|
|
82
|
+
def initialize(prefix: nil, size: :xl, draws: nil, **options)
|
|
83
|
+
refuse_body_sources(options)
|
|
84
|
+
unless draws.nil? || (draws.respond_to?(:seed) && draws.respond_to?(:step))
|
|
85
|
+
raise ArgumentError, "draws: must respond to seed(width) and step(width), or be nil, got: #{draws.inspect}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
@options = { prefix: prefix, size: size }.merge(options)
|
|
89
|
+
check_keys(@options)
|
|
90
|
+
Options.resolve(apply_xs_default(@options).merge(time: 0))
|
|
91
|
+
@draws = draws
|
|
92
|
+
@states = {}
|
|
93
|
+
@mutex = Mutex.new
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Generates the next id: its time and random field follow the monotonic
|
|
97
|
+
# rule above for the call's prefix and random width. Safe to call from
|
|
98
|
+
# several threads at once on one generator.
|
|
99
|
+
#
|
|
100
|
+
# @param time [Integer, Time, nil] (nil) the current time, as UXID.generate
|
|
101
|
+
# takes it; nil reads the system clock. The id carries an earlier or
|
|
102
|
+
# later time when the rule says so
|
|
103
|
+
# @param options [Hash] any option {#initialize} takes except :draws, in
|
|
104
|
+
# place of the generator's own for this call
|
|
105
|
+
# @return [String] the id
|
|
106
|
+
# @raise [UXID::Error] :from_with_monotonic and :rand_with_monotonic as
|
|
107
|
+
# for {#initialize}; :compact_time_out_of_range as for UXID.generate,
|
|
108
|
+
# for the time the id would carry
|
|
109
|
+
# @raise [ArgumentError] for an option UXID.generate refuses, a request
|
|
110
|
+
# with no random field, a draw outside its range, or a step that would
|
|
111
|
+
# carry the time past 2**48 - 1. A call that raises leaves the state as
|
|
112
|
+
# it was
|
|
113
|
+
#
|
|
114
|
+
# @example
|
|
115
|
+
# generator = UXID::Monotonic.new(prefix: "usr")
|
|
116
|
+
# generator.generate # the system clock
|
|
117
|
+
# generator.generate(time: 1_700_000_000_000) # a fixed time
|
|
118
|
+
# generator.generate(prefix: "cus", size: :m) # another prefix and size
|
|
119
|
+
def generate(time: nil, **options)
|
|
120
|
+
refuse_body_sources(options)
|
|
121
|
+
check_keys(options)
|
|
122
|
+
request = Options.resolve(apply_xs_default(@options.merge(options)).merge(time: time))
|
|
123
|
+
width = request.width
|
|
124
|
+
raise ArgumentError, "a monotonic id needs a random field, but this request's random width is 0" if width.zero?
|
|
125
|
+
|
|
126
|
+
@mutex.synchronize { next_id(request, width) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def refuse_body_sources(options)
|
|
132
|
+
unless options[:from].nil?
|
|
133
|
+
raise Error.new(:from_with_monotonic,
|
|
134
|
+
"from: cannot be used with monotonic generation: a deterministic id has no time or " \
|
|
135
|
+
"random field to order")
|
|
136
|
+
end
|
|
137
|
+
return if options[:rand].nil?
|
|
138
|
+
|
|
139
|
+
raise Error.new(:rand_with_monotonic,
|
|
140
|
+
"rand: cannot be used with monotonic generation: the generator fills the random field")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def check_keys(options)
|
|
144
|
+
unknown = options.keys - STREAM_OPTIONS - %i[from rand]
|
|
145
|
+
return if unknown.empty?
|
|
146
|
+
|
|
147
|
+
raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Compact time for xs unless the caller set compact_time; nil from and
|
|
151
|
+
# rand are dropped, as UXID.generate drops them.
|
|
152
|
+
def apply_xs_default(options)
|
|
153
|
+
options = options.except(:from, :rand)
|
|
154
|
+
return options if options.key?(:compact_time) || !XS_NAMES.include?(options[:size].to_s)
|
|
155
|
+
|
|
156
|
+
options.merge(compact_time: true)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Runs under the mutex. The state is written only once the id is built,
|
|
160
|
+
# so a call that raises leaves it as it was.
|
|
161
|
+
def next_id(request, width)
|
|
162
|
+
key = [request.prefix.to_s, width]
|
|
163
|
+
state = advance(@states[key], request.millis, width, request.byte_source)
|
|
164
|
+
unless state.millis <= Options::TIME_MAX
|
|
165
|
+
raise ArgumentError, "time: a step past #{Options::TIME_MAX} ms would carry the id beyond the largest time"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
id = Encoder.generate(request.with(millis: state.millis, rand: bytes(state.value, width)))
|
|
169
|
+
@states[key] = state
|
|
170
|
+
id
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def advance(last, millis, width, byte_source)
|
|
174
|
+
return State.new(millis: millis, value: seed(width, byte_source)) if last.nil? || millis > last.millis
|
|
175
|
+
|
|
176
|
+
value = last.value + step(width, byte_source)
|
|
177
|
+
return State.new(millis: last.millis, value: value) if value < (1 << (8 * width))
|
|
178
|
+
|
|
179
|
+
State.new(millis: last.millis + 1, value: seed(width, byte_source))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def seed(width, byte_source)
|
|
183
|
+
value = @draws.nil? ? integer(Encoder.draw(byte_source, width)) : @draws.seed(width)
|
|
184
|
+
checked(value, 0, (1 << (8 * width)) - 1, "seed")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def step(width, byte_source)
|
|
188
|
+
bits = 4 * width
|
|
189
|
+
value =
|
|
190
|
+
if @draws.nil?
|
|
191
|
+
(integer(Encoder.draw(byte_source, (bits + 7) / 8)) & ((1 << bits) - 1)) + 1
|
|
192
|
+
else
|
|
193
|
+
@draws.step(width)
|
|
194
|
+
end
|
|
195
|
+
checked(value, 1, 1 << bits, "step")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The value as width bytes, big-endian.
|
|
199
|
+
def bytes(value, width)
|
|
200
|
+
[value.to_s(16).rjust(width * 2, "0")].pack("H*")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def integer(bytes)
|
|
204
|
+
bytes.unpack1("H*").to_i(16)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def checked(value, low, high, name)
|
|
208
|
+
return value if value.is_a?(Integer) && value.between?(low, high)
|
|
209
|
+
|
|
210
|
+
raise ArgumentError, "draws: #{name} must be an Integer from #{low} to #{high}, got: #{value.inspect}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
data/lib/uxid/options.rb
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UXID
|
|
4
|
+
# Checks UXID.generate's options and resolves them to what the encoder
|
|
5
|
+
# needs. Internal: UXID.generate documents every option.
|
|
6
|
+
module Options
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
prefix: nil, size: :xl, rand_size: nil, compact_time: false, case: :lower,
|
|
9
|
+
delimiter: "_", time: nil, rand: nil, byte_source: nil, from: nil
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
SIZES = {
|
|
13
|
+
xs: :xs, xsmall: :xs, s: :s, small: :s, m: :m, medium: :m,
|
|
14
|
+
l: :l, large: :l, xl: :xl, xlarge: :xl
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# Random field bytes per size, standard time then compact time.
|
|
18
|
+
RAND_BYTES = {
|
|
19
|
+
xs: [0, 1], s: [2, 3], m: [5, 6], l: [7, 8], xl: [10, 10]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Hash characters per size in a deterministic body, which is the marker
|
|
23
|
+
# and then these characters.
|
|
24
|
+
HASH_CHARS = { xs: 9, s: 13, m: 17, l: 21, xl: 25 }.freeze
|
|
25
|
+
|
|
26
|
+
MAX_RAND_BYTES = 10
|
|
27
|
+
TIME_MAX = (2**48) - 1
|
|
28
|
+
|
|
29
|
+
# What the encoder reads: the checked options, the time in milliseconds
|
|
30
|
+
# and the random field's width in bytes.
|
|
31
|
+
Resolved = Data.define(:prefix, :delimiter, :upper, :compact_time, :millis, :width, :rand, :byte_source)
|
|
32
|
+
|
|
33
|
+
# What the encoder reads for a deterministic body: the checked options
|
|
34
|
+
# and the number of hash characters.
|
|
35
|
+
Deterministic = Data.define(:prefix, :delimiter, :upper, :from, :count)
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# @return [Resolved, Deterministic]
|
|
40
|
+
def resolve(options)
|
|
41
|
+
check_keys(options)
|
|
42
|
+
options = DEFAULTS.merge(options)
|
|
43
|
+
check_types(options)
|
|
44
|
+
options[:from].nil? ? time_based(options) : deterministic(options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def time_based(options)
|
|
48
|
+
Resolved.new(
|
|
49
|
+
prefix: options[:prefix], delimiter: options[:delimiter], upper: upper?(options[:case]),
|
|
50
|
+
compact_time: options[:compact_time], millis: millis_for(options[:time]), width: width(options),
|
|
51
|
+
rand: options[:rand], byte_source: options[:byte_source]
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A deterministic body has no time field and no random field, so the
|
|
56
|
+
# options that shape those (time, compact_time, rand_size, byte_source)
|
|
57
|
+
# are not read; a fixed random field contradicts it and is refused.
|
|
58
|
+
def deterministic(options)
|
|
59
|
+
unless options[:rand].nil?
|
|
60
|
+
raise Error.new(:rand_with_from,
|
|
61
|
+
"rand: and from: cannot be given together: a deterministic body has no random field")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Deterministic.new(
|
|
65
|
+
prefix: options[:prefix], delimiter: options[:delimiter], upper: upper?(options[:case]),
|
|
66
|
+
from: options[:from], count: HASH_CHARS.fetch(size_for(options[:size]))
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def width(options)
|
|
71
|
+
width_for(size_for(options[:size]), options[:compact_time], options[:rand_size])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def check_keys(options)
|
|
75
|
+
unknown = options.keys - DEFAULTS.keys
|
|
76
|
+
return if unknown.empty?
|
|
77
|
+
|
|
78
|
+
raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def check_types(options)
|
|
82
|
+
check(options, :prefix, "a String or nil") { |value| value.nil? || value.is_a?(String) }
|
|
83
|
+
check(options, :delimiter, "a non-empty String") { |value| value.is_a?(String) && !value.empty? }
|
|
84
|
+
check(options, :compact_time, "true or false") { |value| [true, false].include?(value) }
|
|
85
|
+
check_body_sources(options)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The options a body's bytes come from: a fixed random field, the string
|
|
89
|
+
# a deterministic body hashes, and the byte source.
|
|
90
|
+
def check_body_sources(options)
|
|
91
|
+
check(options, :rand, "a String of bytes or nil") { |value| value.nil? || value.is_a?(String) }
|
|
92
|
+
check(options, :from, "a String or nil") { |value| value.nil? || value.is_a?(String) }
|
|
93
|
+
check(options, :byte_source, "an object responding to call(count), or nil") do |value|
|
|
94
|
+
value.nil? || value.respond_to?(:call)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def check(options, key, expected)
|
|
99
|
+
return if yield(options[key])
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "#{key}: must be #{expected}, got: #{options[key].inspect}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def upper?(letter_case)
|
|
105
|
+
case letter_case.to_s
|
|
106
|
+
when "lower" then false
|
|
107
|
+
when "upper" then true
|
|
108
|
+
else raise ArgumentError, "case: must be :lower or :upper, got: #{letter_case.inspect}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def size_for(size)
|
|
113
|
+
SIZES.fetch(size.to_s.to_sym) do
|
|
114
|
+
raise ArgumentError, "size: must be one of #{SIZES.keys.join(", ")}, got: #{size.inspect}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def millis_for(time)
|
|
119
|
+
millis =
|
|
120
|
+
case time
|
|
121
|
+
when nil then Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
|
|
122
|
+
when Time then (time.to_r * 1000).floor
|
|
123
|
+
when Integer then time
|
|
124
|
+
else raise ArgumentError, "time: must be an Integer of milliseconds or a Time, got: #{time.inspect}"
|
|
125
|
+
end
|
|
126
|
+
return millis if millis.between?(0, TIME_MAX)
|
|
127
|
+
|
|
128
|
+
raise ArgumentError, "time: must be from 0 to 2**48 - 1 milliseconds, got: #{millis}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def width_for(size, compact_time, rand_size)
|
|
132
|
+
return RAND_BYTES.fetch(size)[compact_time ? 1 : 0] if rand_size.nil?
|
|
133
|
+
|
|
134
|
+
unless rand_size.is_a?(Integer) && rand_size >= 0
|
|
135
|
+
raise ArgumentError, "rand_size: must be a non-negative Integer, got: #{rand_size.inspect}"
|
|
136
|
+
end
|
|
137
|
+
return rand_size if rand_size <= MAX_RAND_BYTES
|
|
138
|
+
|
|
139
|
+
raise Error.new(:rand_size_too_large, "rand_size: must be at most #{MAX_RAND_BYTES} bytes, got: #{rand_size}")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private_constant :Options
|
|
144
|
+
end
|