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.
data/lib/uxid/encoder.rb CHANGED
@@ -1,180 +1,111 @@
1
- module UXID
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
- def encode
12
- @model.time_encoded = encode_time
13
- @model.entropy_encoded = public_send "encode_entropy_#{@model.rand_size}"
3
+ require "digest"
4
+ require "securerandom"
14
5
 
15
- @model.encoded
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
- def encode_entropy_0; ""; end
19
-
20
- def encode_entropy_1
21
- b0, _b1 = @model.entropy_bytes
22
-
23
- UXID::CROCKFORD_ENCODING[(b0&248)>>3] +
24
- UXID::CROCKFORD_ENCODING[((b0&7)<<2)]
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
- def encode_entropy_2
28
- b0, b1 = @model.entropy_bytes
29
-
30
- UXID::CROCKFORD_ENCODING[(b0&248)>>3] +
31
- UXID::CROCKFORD_ENCODING[((b0&7)<<2) | ((b1&192)>>6)] +
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 encode_entropy_3
37
- b0, b1, b2 = @model.entropy_bytes
38
-
39
- UXID::CROCKFORD_ENCODING[(b0&248)>>3] +
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 encode_entropy_4
47
- b0, b1, b2, b3 = @model.entropy_bytes
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
- def encode_entropy_5
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 encode_entropy_6
72
- b0, b1, b2, b3, b4, b5 = @model.entropy_bytes
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
- def encode_entropy_7
87
- b0, b1, b2, b3, b4, b5, b6 = @model.entropy_bytes
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
- def encode_entropy_8
104
- b0, b1, b2, b3, b4, b5, b6, b7 = @model.entropy_bytes
105
-
106
- UXID::CROCKFORD_ENCODING[(b0&248)>>3] +
107
- UXID::CROCKFORD_ENCODING[((b0&7)<<2) | ((b1&192)>>6)] +
108
- UXID::CROCKFORD_ENCODING[(b1&62)>>1] +
109
- UXID::CROCKFORD_ENCODING[((b1&1)<<4) | ((b2&240)>>4)] +
110
- UXID::CROCKFORD_ENCODING[((b2&15)<<1) | ((b3&128)>>7)] +
111
- UXID::CROCKFORD_ENCODING[(b3&124)>>2] +
112
- UXID::CROCKFORD_ENCODING[((b3&3)<<3) | ((b4&224)>>5)] +
113
- UXID::CROCKFORD_ENCODING[b4&31] +
114
- UXID::CROCKFORD_ENCODING[(b5&248)>>3] +
115
- UXID::CROCKFORD_ENCODING[((b5&7)<<2) | ((b6&192)>>6)] +
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
- def encode_entropy_9
122
- b0, b1, b2, b3, b4, b5, b6, b7, b8 = @model.entropy_bytes
123
-
124
- UXID::CROCKFORD_ENCODING[(b0&248)>>3] +
125
- UXID::CROCKFORD_ENCODING[((b0&7)<<2) | ((b1&192)>>6)] +
126
- UXID::CROCKFORD_ENCODING[(b1&62)>>1] +
127
- UXID::CROCKFORD_ENCODING[((b1&1)<<4) | ((b2&240)>>4)] +
128
- UXID::CROCKFORD_ENCODING[((b2&15)<<1) | ((b3&128)>>7)] +
129
- UXID::CROCKFORD_ENCODING[(b3&124)>>2] +
130
- UXID::CROCKFORD_ENCODING[((b3&3)<<3) | ((b4&224)>>5)] +
131
- UXID::CROCKFORD_ENCODING[b4&31] +
132
- UXID::CROCKFORD_ENCODING[(b5&248)>>3] +
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
- def encode_entropy_10
142
- b0, b1, b2, b3, b4, b5, b6, b7, b8, b9 = @model.entropy_bytes
143
-
144
- UXID::CROCKFORD_ENCODING[(b0&248)>>3] +
145
- UXID::CROCKFORD_ENCODING[((b0&7)<<2) | ((b1&192)>>6)] +
146
- UXID::CROCKFORD_ENCODING[(b1&62)>>1] +
147
- UXID::CROCKFORD_ENCODING[((b1&1)<<4) | ((b2&240)>>4)] +
148
- UXID::CROCKFORD_ENCODING[((b2&15)<<1) | ((b3&128)>>7)] +
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 encode_time
163
- # Get the 5 bytes of the 48bit time
164
- b0, b1, b2, b3, b4, b5 = @model.time_bytes
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
@@ -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
data/lib/uxid/version.rb CHANGED
@@ -1,3 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module UXID
2
- VERSION = "0.2.3"
4
+ # The gem's version.
5
+ VERSION = "1.0.0"
3
6
  end