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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ddc9c523df0274739dd977feb61799c5bccba6c0319ac9caa89a97b16d23178
|
|
4
|
+
data.tar.gz: 5f3e9b5660bf0406fe6be9882ddbf0adffa3851c4ef64304cf091df89d860893
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 985179a75fa0cdf3bfb8f11b7dc4307c2096fd32f19ef8b1b670c3bfc3acbf7fa429b7a47a2f42f3557733a9d6f908dea47895319ce76cdd267fc16bd6a1f248
|
|
7
|
+
data.tar.gz: 36bc846998cf71c55e698b3bad2c7df452d3257116c447eb4ad001308ec09964c23fae324aea7d9f3dfd3cf0e434d7549a10517d673432b3a88720cf7fbbbbb9
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,36 @@
|
|
|
3
3
|
* (Nothing yet)
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
### 1.0.0 / 2026-10-19
|
|
7
|
+
|
|
8
|
+
A rewrite of the gem under its existing name, and a breaking release: the
|
|
9
|
+
0.2.x API is gone. The gem now implements the wire format of the Elixir
|
|
10
|
+
reference implementation, [uxid-ex](https://github.com/riddler/uxid-ex), and
|
|
11
|
+
passes every case of that implementation's conformance corpus at the tag
|
|
12
|
+
`v2.10.0`, vendored under `conformance/` (`conformance/SOURCE.json` records the
|
|
13
|
+
tag, its commit and the corpus hash). An ID written by 0.2 with a symbol size
|
|
14
|
+
has the layout of the same size in 1.0 apart from its case: 0.2 wrote upper
|
|
15
|
+
case and 1.0 writes lowercase by default (`case: :upper` keeps upper case), and
|
|
16
|
+
`UXID.decode` reads either case. To stay on 0.2, pin `gem "uxid", "~> 0.2"`;
|
|
17
|
+
the README's "Upgrading from 0.2" lists what changes for a caller.
|
|
18
|
+
|
|
19
|
+
**Added**
|
|
20
|
+
|
|
21
|
+
* `UXID.generate(from: "...")` builds a deterministic id: the marker `z` (`Z` in an upper-case id) and then the first bits of the SHA-256 of the prefix, the `from` string and the size, as uxid-ex's wire format defines it, so the same arguments always give the same id and it sorts after every time-based id with its prefix and case. `from` (and the prefix) are hashed as their bytes: valid text in an encoding other than UTF-8 is transcoded to UTF-8 first, and a binary String, one not valid in its own encoding, or one Ruby cannot transcode is hashed as its own bytes, so a raw-byte key such as a 16-byte UUID works. `rand:` with `from:` raises `UXID::Error` with code `:rand_with_from`; the time and random-field options do not apply to a deterministic id and are not read.
|
|
22
|
+
* `UXID.decode(id, delimiter: "_", now: nil)` reads an id back as uxid-ex's wire format defines it and never raises on malformed input. A well-formed id gives a frozen `UXID::Decoded` with `prefix` (nil when there is no delimiter), `size` (`:xs` to `:xl`), `compact?`, `deterministic?` and `time` (milliseconds, nil for a deterministic id); the random field is not read back. Anything else gives a frozen `UXID::Invalid` whose `reason` is `:empty`, `:invalid_length` or `:invalid_character`, the first rule the body fails in that order, with lengths counted in bytes; a value that is not a String is `:invalid_character`. Both answer `valid?`. Body lengths 10 and 18 are read as standard xs and standard m, so a compact xs or compact m id decodes to its size but not its time. A compact time takes its high bits from `now:` (default the system clock), less 2**40 ms when that puts it more than a day ahead.
|
|
23
|
+
* `UXID.valid?(id, prefix: nil, delimiter: "_")` is whether the id decodes and, when `prefix:` is given, has exactly that prefix; `UXID.deterministic?(id, delimiter: "_")` is whether it decodes to a deterministic body. Neither raises on the id.
|
|
24
|
+
* `UXID::Monotonic.new(prefix: nil, size: :xl, draws: nil, **options)` is a monotonic generator, and its `#generate(time: nil, **options)` gives ids that increase strictly for each prefix and random width, within one millisecond and when the clock moves back, by the monotonic rule of uxid-ex's wire format: a later time takes a fresh random value, an equal or earlier one keeps the last time and adds a step drawn uniformly from 1 to 2**(4w), and a step that overflows the field moves to the next millisecond with a fresh value. The options are `UXID.generate`'s; those given to `new` are the defaults of every call. Size `:xs` uses compact time unless `compact_time:` is given, and a request with no random field is refused. The state belongs to the instance, guarded by a Mutex inside it, so one generator may be shared across threads; nothing is kept at class level or globally. Random values come from the byte source (default `SecureRandom`), or from `draws:`, an object answering `seed(width)` and `step(width)`, for replaying recorded draws. `from:` raises `UXID::Error` with code `:from_with_monotonic`, and `rand:` with `:rand_with_monotonic`.
|
|
25
|
+
|
|
26
|
+
**Changed**
|
|
27
|
+
|
|
28
|
+
* Breaking: `UXID.generate` now builds the id uxid-ex's wire format defines, from the keyword options prefix, size, rand_size, compact_time, case, delimiter and time; 0.2.x ids are not reproduced, so callers re-read the option list in the method's doc. Random bytes come from an injectable `byte_source` (default `SecureRandom.random_bytes`), and `rand:` fixes the random field for test vectors only. A refused request raises `UXID::Error`, an `ArgumentError` whose `#code` names the refusal.
|
|
29
|
+
* Breaking: Ruby 3.2 or later is required, where 0.2 asked for 2.3; stay on 0.2 on an older Ruby.
|
|
30
|
+
|
|
31
|
+
**Removed**
|
|
32
|
+
|
|
33
|
+
* Breaking: `UXID.new`, `UXID::Model` and the 0.2 encoder class and constants are removed, and `UXID.generate` no longer takes a Hash; call `UXID.generate` with keyword options, or pin `~> 0.2`.
|
|
34
|
+
|
|
35
|
+
|
|
6
36
|
### 0.2.3 / 2020-11-29
|
|
7
37
|
|
|
8
38
|
* Updates project URL
|
data/README.md
CHANGED
|
@@ -1,75 +1,173 @@
|
|
|
1
1
|
# UXID
|
|
2
2
|
|
|
3
|
-
![MIT License][badge_license_url]
|
|
4
|
-
![
|
|
3
|
+
[![MIT License][badge_license_url]](LICENSE)
|
|
4
|
+
[![CI][badge_ci_url]](https://github.com/riddler/uxid-rb/actions/workflows/ci.yml)
|
|
5
|
+
[![Gem Version][badge_version_url]](https://rubygems.org/gems/uxid)
|
|
6
|
+
|
|
7
|
+
User eXperience focused IDentifiers for Ruby: prefixed, K-sortable,
|
|
8
|
+
Stripe-style IDs a person can read, copy and route back to their resource. An
|
|
9
|
+
ID looks like `usr_01m5ahke2t2v9sczzafsbf0y2e`: the prefix names the resource,
|
|
10
|
+
and the body carries a timestamp and randomness in lowercase Crockford Base32.
|
|
11
|
+
The IDs are wire-compatible with the Elixir reference implementation,
|
|
12
|
+
[uxid-ex](https://github.com/riddler/uxid-ex), and the gem is checked against
|
|
13
|
+
that implementation's conformance corpus.
|
|
14
|
+
|
|
15
|
+
## Why UXID
|
|
16
|
+
|
|
17
|
+
An auto-increment key leaks how many rows a table holds and invites
|
|
18
|
+
enumeration; a random UUID fixes both but is long, unordered and anonymous, so
|
|
19
|
+
in a log line, a URL or a support thread nobody can tell what it points at, and
|
|
20
|
+
a double-click selects only part of it. A UXID names its resource in its
|
|
21
|
+
prefix, selects whole on a double-click, reads aloud without ambiguity, sorts
|
|
22
|
+
by creation time so it indexes well, and is generated in the application with
|
|
23
|
+
no coordination between nodes. Its size is tunable for low-cardinality
|
|
24
|
+
resources, a monotonic generator keeps a burst within one millisecond unique
|
|
25
|
+
and ordered, and a deterministic mode maps the same input to the same ID.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Add `uxid` to your Gemfile:
|
|
5
30
|
|
|
31
|
+
```ruby
|
|
32
|
+
gem "uxid", "~> 1.0"
|
|
33
|
+
```
|
|
6
34
|
|
|
7
|
-
|
|
35
|
+
And then execute:
|
|
8
36
|
|
|
9
|
-
|
|
10
|
-
* Work well with copy and paste (double clicking selects the entire ID)
|
|
11
|
-
* Can be shortened for low cardinality resources
|
|
12
|
-
* Are secure against enumeration attacks
|
|
13
|
-
* Can be generated by application code (not tied to the datastore)
|
|
14
|
-
* Are K-sortable (lexicographically sortable by time - works well with datastore indexing)
|
|
15
|
-
* Do not require any coordination (human or automated) at startup, or generation
|
|
16
|
-
* Are very unlikely to collide (more likely with less randomness)
|
|
17
|
-
* Are easily and accurately transmitted to another human using a telephone
|
|
37
|
+
$ bundle install
|
|
18
38
|
|
|
19
|
-
|
|
39
|
+
Or install it yourself as:
|
|
20
40
|
|
|
21
|
-
|
|
41
|
+
$ gem install uxid
|
|
22
42
|
|
|
23
|
-
|
|
43
|
+
## Basic usage
|
|
24
44
|
|
|
25
45
|
```ruby
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
require "uxid"
|
|
47
|
+
|
|
48
|
+
# No options generates a plain ID: a time field and a random field
|
|
49
|
+
UXID.generate # "01m5ahke2tebfrqcks88r0d5ta"
|
|
50
|
+
|
|
51
|
+
# A prefix names the resource
|
|
52
|
+
UXID.generate(prefix: "usr") # "usr_01m5ahke2t2v9sczzafsbf0y2e"
|
|
53
|
+
|
|
54
|
+
# Shrink the random part for low-cardinality resources
|
|
55
|
+
# Sizes: :xs :s :m :l :xl (or :xsmall :small :medium :large :xlarge)
|
|
56
|
+
UXID.generate(prefix: "cus", size: :small) # "cus_01m5ahke2t2e40"
|
|
57
|
+
|
|
58
|
+
# Deterministic: the same input gives the same ID, with the prefix as namespace
|
|
59
|
+
UXID.generate(prefix: "usr", from: "alice@example.com")
|
|
60
|
+
# => "usr_zcvt7epac0t1ebcsjfyf7cwz25"
|
|
61
|
+
|
|
62
|
+
# Read an ID back, or check it
|
|
63
|
+
UXID.decode("usr_01hf7yat00")
|
|
64
|
+
# => #<data UXID::Decoded prefix="usr", size=:xs, compact=false, deterministic=false, time=1700000000000>
|
|
65
|
+
UXID.valid?("usr_01hf7yat00", prefix: "usr") # => true
|
|
66
|
+
UXID.valid?("usr_01hf7yat00", prefix: "cus") # => false
|
|
67
|
+
UXID.deterministic?("usr_zcvt7epac0t1ebcsjfyf7cwz25") # => true
|
|
68
|
+
|
|
69
|
+
# Monotonic: IDs from one generator increase strictly, also within one
|
|
70
|
+
# millisecond. You hold the generator; the gem keeps no global state.
|
|
71
|
+
generator = UXID::Monotonic.new(prefix: "usr", size: :s)
|
|
72
|
+
generator.generate # "usr_01m5ahke2te6h0"
|
|
73
|
+
generator.generate # a larger one
|
|
35
74
|
```
|
|
36
75
|
|
|
76
|
+
`UXID.decode` never raises on malformed input: it answers a `UXID::Invalid`
|
|
77
|
+
whose `reason` is `:empty`, `:invalid_length` or `:invalid_character`. A request
|
|
78
|
+
the wire format refuses, such as `rand_size: 11`, raises `UXID::Error`, an
|
|
79
|
+
`ArgumentError` whose `code` names the refusal.
|
|
37
80
|
|
|
38
|
-
##
|
|
81
|
+
## Upgrading from 0.2
|
|
39
82
|
|
|
40
|
-
|
|
83
|
+
Version 1.0.0 is a different format and API under the same gem name. It is a
|
|
84
|
+
breaking release, not an upgrade path for 0.2 code. To stay on 0.2, pin it:
|
|
41
85
|
|
|
42
86
|
```ruby
|
|
43
|
-
gem "uxid"
|
|
87
|
+
gem "uxid", "~> 0.2"
|
|
44
88
|
```
|
|
45
89
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
90
|
+
To move to 1.0:
|
|
91
|
+
|
|
92
|
+
- **The API is replaced.** `UXID.generate` takes keyword options (`prefix`,
|
|
93
|
+
`size`, `rand_size`, `compact_time`, `case`, `delimiter`, `time`, `rand`,
|
|
94
|
+
`byte_source`, `from`) and no longer takes a Hash. `UXID.new`, `UXID::Model`
|
|
95
|
+
and the 0.2 encoder class and constants are gone. `UXID.decode`,
|
|
96
|
+
`UXID.valid?`, `UXID.deterministic?` and `UXID::Monotonic` are new.
|
|
97
|
+
- **Sizes are symbols or their names.** An Integer `size` is refused with an
|
|
98
|
+
`ArgumentError`; use `rand_size` for a specific number of random bytes, from
|
|
99
|
+
0 to 10. A String `size` such as `"small"` was read by 0.2 as the largest
|
|
100
|
+
size, with a warning; in 1.0 it means what the name says, so such a call
|
|
101
|
+
gives a shorter ID and no error.
|
|
102
|
+
- **IDs are written in lowercase by default.** 0.2 wrote upper case. Pass
|
|
103
|
+
`case: :upper` to keep writing it. `UXID.decode` reads either case.
|
|
104
|
+
- **Errors are `UXID::Error`.** It is an `ArgumentError`, where 0.2's was a
|
|
105
|
+
`StandardError`, and its `code` names the refusal.
|
|
106
|
+
- **Ruby 3.2 or later** is required, where 0.2 asked for 2.3.
|
|
107
|
+
- **Stored IDs.** An ID written by 0.2 with a symbol size has the layout of the
|
|
108
|
+
same size in 1.0, apart from its case, and `UXID.decode` reads it. Compare IDs
|
|
109
|
+
in one case, or normalize them, if you mix the two.
|
|
110
|
+
|
|
111
|
+
## Compatibility
|
|
112
|
+
|
|
113
|
+
Ruby 3.2 or later (`required_ruby_version` in the gemspec). CI runs the test
|
|
114
|
+
suite on every Ruby minor from 3.2 up to the version `mise.toml` pins, and the
|
|
115
|
+
full quality gate on that pinned version. There are no runtime dependencies;
|
|
116
|
+
random bytes come from `SecureRandom` unless you give a `byte_source`. One
|
|
117
|
+
`UXID::Monotonic` instance may be shared across threads: its state is guarded
|
|
118
|
+
by a Mutex inside the instance, and nothing is kept at class level or globally.
|
|
119
|
+
IDs from different generators carry no order between them.
|
|
120
|
+
|
|
121
|
+
## Documentation
|
|
122
|
+
|
|
123
|
+
- Look up
|
|
124
|
+
- Every public method and class carries a YARD doc comment in `lib/`:
|
|
125
|
+
`UXID.generate`, `UXID.decode`, `UXID.valid?`, `UXID.deterministic?`,
|
|
126
|
+
`UXID::Monotonic`, `UXID::Decoded`, `UXID::Invalid` and `UXID::Error`.
|
|
127
|
+
- [UXID wire format][guide_wire_format_url]: the normative layout of an ID -
|
|
128
|
+
alphabet, bit packing, sizes, prefix grammar, deterministic and monotonic
|
|
129
|
+
bodies, and decoding - which this gem implements.
|
|
130
|
+
- [The changelog](CHANGELOG.md): what changed in each version.
|
|
131
|
+
- [The conformance corpus](conformance/README.md): the cases every
|
|
132
|
+
implementation of the wire format passes, vendored here from uxid-ex.
|
|
133
|
+
- Understand
|
|
134
|
+
- [Why a UXID has a prefix, a time and randomness][guide_why_url]: what each
|
|
135
|
+
part of an ID is for, and what you trade when you tune it.
|
|
136
|
+
- [Why monotonic mode exists and what it costs][guide_monotonic_url]:
|
|
137
|
+
same-millisecond uniqueness and ordering, and the security tradeoff.
|
|
138
|
+
- [_Designing APIs for humans: object IDs_][stripe_ids_url]: the Stripe ID
|
|
139
|
+
design many of UXID's choices follow.
|
|
54
140
|
|
|
55
141
|
## Development
|
|
56
142
|
|
|
57
|
-
After checking out the repo, run `
|
|
143
|
+
After checking out the repo, run `mise install` for the Ruby `mise.toml` pins
|
|
144
|
+
and `mise exec -- bundle install` for the development tools. Then
|
|
145
|
+
`mise exec -- bundle exec rake test` runs the tests, and
|
|
146
|
+
`mise exec -- bundle exec rake` runs the full gate: RuboCop, the corpus
|
|
147
|
+
integrity check and the tests under a coverage minimum.
|
|
58
148
|
|
|
59
|
-
|
|
149
|
+
A release is published by CI, never from a checkout: pushing a tag `vX.Y.Z`
|
|
150
|
+
that names `UXID::VERSION` runs `.github/workflows/release.yml`, which builds
|
|
151
|
+
and pushes the gem to [rubygems.org](https://rubygems.org). `rake release`
|
|
152
|
+
refuses to run anywhere else.
|
|
60
153
|
|
|
61
154
|
## Contributing
|
|
62
155
|
|
|
63
|
-
Bug reports and pull requests are welcome on GitHub at
|
|
64
|
-
|
|
156
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
157
|
+
https://github.com/riddler/uxid-rb.
|
|
65
158
|
|
|
66
159
|
## License
|
|
67
160
|
|
|
68
|
-
The gem is available as open source under the terms of the
|
|
161
|
+
The gem is available as open source under the terms of the
|
|
162
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
|
69
163
|
|
|
70
164
|
<!-- LINKS -->
|
|
71
|
-
[
|
|
165
|
+
[guide_wire_format_url]: https://hexdocs.pm/uxid/wire-format.html
|
|
166
|
+
[guide_why_url]: https://hexdocs.pm/uxid/why-prefix-time-randomness.html
|
|
167
|
+
[guide_monotonic_url]: https://hexdocs.pm/uxid/monotonic.html
|
|
168
|
+
[stripe_ids_url]: https://dev.to/stripe/designing-apis-for-humans-object-ids-3o5a
|
|
72
169
|
|
|
73
170
|
<!-- BADGES -->
|
|
74
171
|
[badge_license_url]: https://img.shields.io/badge/license-MIT-brightgreen.svg?cacheSeconds=3600?style=flat-square
|
|
172
|
+
[badge_ci_url]: https://github.com/riddler/uxid-rb/actions/workflows/ci.yml/badge.svg
|
|
75
173
|
[badge_version_url]: https://badge.fury.io/rb/uxid.svg
|
data/lib/uxid/decoded.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UXID
|
|
4
|
+
# What UXID.decode reads from a well-formed id: its prefix, its size and
|
|
5
|
+
# time form, whether it is deterministic, and its time. The random field is
|
|
6
|
+
# not read back. A frozen value: two decodings of the same id are equal.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] prefix
|
|
9
|
+
# @return [String, nil] everything before the last delimiter, nil when the
|
|
10
|
+
# id has no delimiter; an id that starts with the delimiter (not one a
|
|
11
|
+
# producer emits) has the empty string, which reads as no prefix
|
|
12
|
+
# @!attribute [r] size
|
|
13
|
+
# @return [Symbol] the size the body length gives: :xs, :s, :m, :l or :xl
|
|
14
|
+
# @!attribute [r] compact
|
|
15
|
+
# @return [Boolean] whether the body length is a compact one; see
|
|
16
|
+
# {#compact?}
|
|
17
|
+
# @!attribute [r] deterministic
|
|
18
|
+
# @return [Boolean] whether the body starts with the marker z or Z; see
|
|
19
|
+
# {#deterministic?}
|
|
20
|
+
# @!attribute [r] time
|
|
21
|
+
# @return [Integer, nil] the time in milliseconds since the Unix epoch, nil
|
|
22
|
+
# for a deterministic body. A compact time's high bits come from the
|
|
23
|
+
# decoder's clock (UXID.decode's :now)
|
|
24
|
+
Decoded = Data.define(:prefix, :size, :compact, :deterministic, :time) do
|
|
25
|
+
# @return [true] a decoded id is always valid; {Invalid#valid?} is false,
|
|
26
|
+
# so a caller can branch on the result without a type check
|
|
27
|
+
def valid?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] whether the body length is a compact one
|
|
32
|
+
def compact?
|
|
33
|
+
compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Boolean] whether the body is deterministic (no time)
|
|
37
|
+
def deterministic?
|
|
38
|
+
deterministic
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# What UXID.decode answers for a string it refuses: the reason, named as
|
|
43
|
+
# the wire-format spec and its conformance corpus name it. A frozen value.
|
|
44
|
+
#
|
|
45
|
+
# @!attribute [r] reason
|
|
46
|
+
# @return [Symbol] :empty, :invalid_length or :invalid_character
|
|
47
|
+
Invalid = Data.define(:reason) do
|
|
48
|
+
# @return [false] a refused id is never valid; {Decoded#valid?} is true
|
|
49
|
+
def valid?
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/uxid/decoder.rb
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UXID
|
|
4
|
+
# Reads an id back to a Decoded, or refuses it with an Invalid. Internal:
|
|
5
|
+
# the public entry point is UXID.decode, which documents the reading.
|
|
6
|
+
module Decoder
|
|
7
|
+
# Body length in bytes to the size and whether the time is compact. The
|
|
8
|
+
# lengths 10 and 18 are also those of compact xs and compact m; the wire
|
|
9
|
+
# format reads both as standard.
|
|
10
|
+
LENGTHS = {
|
|
11
|
+
10 => [:xs, false], 13 => [:s, true], 14 => [:s, false], 18 => [:m, false],
|
|
12
|
+
21 => [:l, true], 22 => [:l, false], 24 => [:xl, true], 26 => [:xl, false]
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# Each byte of the alphabet, in either case, to its 5-bit value.
|
|
16
|
+
VALUES = (Encoder::LOWER.bytes.each_with_index.to_a + Encoder::UPPER.bytes.each_with_index.to_a).to_h.freeze
|
|
17
|
+
|
|
18
|
+
MARKERS = ["z".ord, "Z".ord].freeze
|
|
19
|
+
COMPACT_WINDOW = 2**40
|
|
20
|
+
ONE_DAY = 86_400_000
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# @param id [Object] the id; anything other than a String is refused
|
|
25
|
+
# @param delimiter [String] the non-empty string between prefix and body
|
|
26
|
+
# @param now [Integer, Time, nil] the decoder's clock for a compact time
|
|
27
|
+
# @return [Decoded, Invalid]
|
|
28
|
+
def decode(id, delimiter, now)
|
|
29
|
+
check_options(delimiter, now)
|
|
30
|
+
return Invalid.new(reason: :invalid_character) unless id.is_a?(String)
|
|
31
|
+
|
|
32
|
+
prefix, body = split(id, delimiter)
|
|
33
|
+
reason = refusal(body)
|
|
34
|
+
reason ? Invalid.new(reason: reason) : decoded(prefix, body, now)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_options(delimiter, now)
|
|
38
|
+
unless delimiter.is_a?(String) && !delimiter.empty?
|
|
39
|
+
raise ArgumentError, "delimiter: must be a non-empty String, got: #{delimiter.inspect}"
|
|
40
|
+
end
|
|
41
|
+
return if now.nil? || now.is_a?(Integer) || now.is_a?(Time)
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "now: must be an Integer of milliseconds, a Time or nil, got: #{now.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The body is everything after the last delimiter and the prefix
|
|
47
|
+
# everything before it; with no delimiter the whole id is the body. The
|
|
48
|
+
# split is on bytes, so an id that is not valid in its encoding splits
|
|
49
|
+
# without raising; the prefix keeps the id's encoding.
|
|
50
|
+
def split(id, delimiter)
|
|
51
|
+
bytes = id.b
|
|
52
|
+
at = bytes.rindex(delimiter.b)
|
|
53
|
+
return [nil, bytes] if at.nil?
|
|
54
|
+
|
|
55
|
+
prefix = bytes.byteslice(0, at).force_encoding(id.encoding).freeze
|
|
56
|
+
[prefix, bytes.byteslice(at + delimiter.bytesize, bytes.bytesize)]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The first rule the body fails, in the spec's order, or nil when it is
|
|
60
|
+
# well formed. Lengths are in bytes.
|
|
61
|
+
def refusal(body)
|
|
62
|
+
return :empty if body.empty?
|
|
63
|
+
return :invalid_length unless LENGTHS.key?(body.bytesize)
|
|
64
|
+
|
|
65
|
+
:invalid_character unless body.each_byte.all? { |byte| VALUES.key?(byte) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def decoded(prefix, body, now)
|
|
69
|
+
size, compact = LENGTHS.fetch(body.bytesize)
|
|
70
|
+
deterministic = MARKERS.include?(body.getbyte(0))
|
|
71
|
+
time =
|
|
72
|
+
if deterministic then nil
|
|
73
|
+
elsif compact then compact_time(value(body, 8), now)
|
|
74
|
+
else standard_time(body)
|
|
75
|
+
end
|
|
76
|
+
Decoded.new(prefix: prefix, size: size, compact: compact, deterministic: deterministic, time: time)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The first 10 characters, the first holding the time's top 3 bits; the
|
|
80
|
+
# first character's higher bits are not read.
|
|
81
|
+
def standard_time(body)
|
|
82
|
+
((VALUES.fetch(body.getbyte(0)) & 7) << 45) | value(body.byteslice(1, 9), 9)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The low 40 bits from the id and the rest from the clock; a result more
|
|
86
|
+
# than a day ahead of the clock is from the window before.
|
|
87
|
+
def compact_time(low, now)
|
|
88
|
+
clock = millis(now)
|
|
89
|
+
time = ((clock >> 40) << 40) | low
|
|
90
|
+
time - clock > ONE_DAY ? time - COMPACT_WINDOW : time
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def value(body, count)
|
|
94
|
+
body.each_byte.first(count).reduce(0) { |acc, byte| (acc << 5) | VALUES.fetch(byte) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def millis(now)
|
|
98
|
+
case now
|
|
99
|
+
when nil then Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
|
|
100
|
+
when Time then (now.to_r * 1000).floor
|
|
101
|
+
else now
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private_constant :Decoder
|
|
107
|
+
end
|