ashid 0.1.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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE.txt +22 -0
- data/README.md +186 -0
- data/lib/ashid/encoder.rb +71 -0
- data/lib/ashid/error.rb +7 -0
- data/lib/ashid/version.rb +5 -0
- data/lib/ashid.rb +155 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4202347d4a935d5d28ecb9b77c9ea91e7ce374b66ec12e70ee24ee031d41f8e5
|
|
4
|
+
data.tar.gz: f5fc04121314a8fdbf012ad06a6a78ad6d103dffbdf12da716ca98e107b290f7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 98959c0286172b5705b5f27732267630097697bad82edc41a381f6b2a73ff6a35694596d8265c975b257d2d64fd3feebd92eefd54e325ece2d570aa8054f77c4
|
|
7
|
+
data.tar.gz: 8a1e40a69897006d9be2a1c14d39b452f38f02d7b70ace342c74d26ca39779ea38021f026815f3e0a6bbe2f0faf638efcee76e8435cc29a150f933da62f8cdd2
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-05-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release of `ashid` Ruby port.
|
|
14
|
+
- `Ashid.generate(prefix, time:, random:)` — time-sortable IDs with optional Stripe-style prefix.
|
|
15
|
+
- `Ashid.generate4(prefix, random1:, random2:)` — UUIDv4-equivalent random-only IDs.
|
|
16
|
+
- `Ashid.parse`, `.prefix`, `.timestamp`, `.time`, `.random`, `.random_bytes` — inspection helpers.
|
|
17
|
+
- `Ashid.valid?` — non-raising predicate.
|
|
18
|
+
- `Ashid.normalize` — canonicalize lookalikes and casing.
|
|
19
|
+
- `Ashid::Encoder` — Crockford Base32 encoder/decoder + CSPRNG.
|
|
20
|
+
- Cross-language parity test against the TypeScript reference implementation.
|
|
21
|
+
- Custom error hierarchy: `Ashid::Error`, `Ashid::InvalidIdError`, `Ashid::InvalidEncodingError`.
|
|
22
|
+
|
|
23
|
+
[Unreleased]: https://github.com/dankozlowski/ashid-ruby/compare/v0.1.0...HEAD
|
|
24
|
+
[0.1.0]: https://github.com/dankozlowski/ashid-ruby/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Kozlowski (Ruby port)
|
|
4
|
+
Copyright (c) 2024 Dathan Guiley / Wilde Agency (original ashid library)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# ashid
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/ashid)
|
|
4
|
+
[](https://github.com/dankozlowski/ashid-ruby/actions/workflows/test.yml)
|
|
5
|
+
[](LICENSE.txt)
|
|
6
|
+
|
|
7
|
+
**Time-sortable unique identifiers with type prefixes.** Ruby port of the [ashid library](https://github.com/wildeagency/ashid), wire-compatible with the TypeScript and Kotlin implementations.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
Ashid.generate("user") # => "user_1je3kvrg000007fn17wx6b"
|
|
11
|
+
Ashid.generate("tx") # => "tx_1je3kvrg1000075n93qdpk"
|
|
12
|
+
Ashid.generate # => "1je3kvrg000007fn17wx6b"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Why ashid?
|
|
16
|
+
|
|
17
|
+
UUIDs are opaque. When you see `550e8400-e29b-41d4-a716-446655440000` in a log, you have no idea what it represents.
|
|
18
|
+
|
|
19
|
+
ashid generates IDs that tell you what they are:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
user_1je3kvrg000007fn17wx6b ← Obviously a user
|
|
23
|
+
tx_1je3kvrg1000075n93qdpk ← Obviously a transaction
|
|
24
|
+
asset_1je3kvrg200005j6x7eygm ← Obviously an asset
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
- **Type prefixes** — Self-documenting IDs, like Stripe (`sk_`, `pi_`, `cus_`).
|
|
30
|
+
- **Time-sortable** — Lexicographic sort = chronological sort.
|
|
31
|
+
- **Crockford Base32** — Case-insensitive, `I/L→1`, `O→0` (no more "is that a zero or an O?").
|
|
32
|
+
- **Double-click selectable** — No hyphens or special characters.
|
|
33
|
+
- **URL-safe** — No encoding required.
|
|
34
|
+
- **Cross-language** — Round-trips with the TypeScript and Kotlin libraries.
|
|
35
|
+
- **Zero runtime dependencies** — Just stdlib `SecureRandom`.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
gem install ashid
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or in a Gemfile:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
gem "ashid"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick Start
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
require "ashid"
|
|
53
|
+
|
|
54
|
+
Ashid.generate # => "1je3kvrg000007fn17wx6b"
|
|
55
|
+
Ashid.generate("user") # => "user_1je3kvrg000007fn17wx6b"
|
|
56
|
+
Ashid.generate4("tok") # => "tok_14d2pf2dbsqqg0zvebn63pags1" (random-only, like UUIDv4)
|
|
57
|
+
|
|
58
|
+
id = Ashid.generate("user")
|
|
59
|
+
Ashid.parse(id) # => {prefix: "user_", encoded_timestamp: "...", encoded_random: "..."}
|
|
60
|
+
Ashid.timestamp(id) # => 1733140800000
|
|
61
|
+
Ashid.time(id) # => 2024-12-02 12:00:00 UTC
|
|
62
|
+
Ashid.valid?(id) # => true
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## API Reference
|
|
66
|
+
|
|
67
|
+
### `Ashid.generate(prefix = nil, time:, random:)`
|
|
68
|
+
|
|
69
|
+
Generate a time-sortable ID, optionally with a prefix.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
Ashid.generate # => "1je3kvrg000007fn17wx6b"
|
|
73
|
+
Ashid.generate("user") # => "user_1je3kvrg000007fn17wx6b"
|
|
74
|
+
Ashid.generate("user", time: 1_733_140_800_000, random: 8_234_567_890_123)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `prefix` (positional, optional): alphanumeric chars are kept and lowercased; everything else is stripped. The trailing `_` delimiter is added automatically — passing `"user"`, `"user_"`, or `"user-"` yields the same result.
|
|
78
|
+
- `time:` (keyword, defaults to current ms): Integer milliseconds since Unix epoch. Range: `0` to `35_184_372_088_831` (Dec 12, 3084).
|
|
79
|
+
- `random:` (keyword, defaults to a secure 64-bit random Integer): non-negative Integer.
|
|
80
|
+
|
|
81
|
+
Raises `ArgumentError` for negative `time`, `time` exceeding the max, or negative `random`.
|
|
82
|
+
|
|
83
|
+
### `Ashid.generate4(prefix = nil, random1:, random2:)`
|
|
84
|
+
|
|
85
|
+
Generate a random-only ID (UUIDv4 equivalent), without time-sortability. 26-char base, ~106 bits of entropy. Useful for tokens, secrets, or any case where unpredictability matters more than ordering.
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Ashid.generate4 # => "14d2pf2dbsqqg0zvebn63pags1" (26 chars)
|
|
89
|
+
Ashid.generate4("tok") # => "tok_14d2pf2dbsqqg0zvebn63pags1"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### `Ashid.parse(id)`
|
|
93
|
+
|
|
94
|
+
Parse an ID into its components.
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
Ashid.parse("user_1je3kvrg000007fn17wx6b")
|
|
98
|
+
# => {prefix: "user_", encoded_timestamp: "1je3kvrg0", encoded_random: "00007fn17wx6b"}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Raises `Ashid::InvalidIdError` for `nil`, empty, or malformed input.
|
|
102
|
+
|
|
103
|
+
### `Ashid.prefix(id)`, `Ashid.timestamp(id)`, `Ashid.time(id)`, `Ashid.random(id)`, `Ashid.random_bytes(id)`
|
|
104
|
+
|
|
105
|
+
Convenience accessors:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
id = Ashid.generate("user")
|
|
109
|
+
|
|
110
|
+
Ashid.prefix(id) # => "user_"
|
|
111
|
+
Ashid.timestamp(id) # => 1733140800000 (Integer ms)
|
|
112
|
+
Ashid.time(id) # => 2024-12-02 12:00:00 UTC (Time object)
|
|
113
|
+
Ashid.random(id) # => 8234567890123 (Integer)
|
|
114
|
+
Ashid.random_bytes(id) # => "\x00\x00..." (8-byte ASCII-8BIT String)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### `Ashid.valid?(id)`
|
|
118
|
+
|
|
119
|
+
Returns `true`/`false`. Never raises, even on `nil` or non-String input.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
Ashid.valid?("user_1je3kvrg000007fn17wx6b") # => true
|
|
123
|
+
Ashid.valid?(nil) # => false
|
|
124
|
+
Ashid.valid?("garbage") # => false
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `Ashid.normalize(id)`
|
|
128
|
+
|
|
129
|
+
Canonicalize an ID: lowercase the prefix and resolve Crockford lookalikes (`I/L → 1`, `O → 0`, `U → V`).
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
Ashid.normalize("USER_IJE3KVRGOOOOO7FNI7WX6B")
|
|
133
|
+
# => "user_1je3kvrg000007fn17wx6b"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Format
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
[prefix_]?[timestamp][random]
|
|
140
|
+
↓ ↓ ↓
|
|
141
|
+
user_ 1je3kvrg0 00007fn17wx6b
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- **With prefix**: variable-length timestamp + 13-char padded random.
|
|
145
|
+
- **Without prefix**: 9-char zero-padded timestamp + 13-char padded random = fixed 22 chars.
|
|
146
|
+
- **`generate4` (random-only)**: 13 + 13 = 26-char base.
|
|
147
|
+
|
|
148
|
+
**Crockford alphabet:** `0123456789abcdefghjkmnpqrstvwxyz`. Excludes `i`, `l`, `o`, `u`. On decode, lookalikes map: `I/L → 1`, `O → 0`, `U → V`.
|
|
149
|
+
|
|
150
|
+
## Cross-language compatibility
|
|
151
|
+
|
|
152
|
+
`ashid` IDs round-trip byte-for-byte across:
|
|
153
|
+
|
|
154
|
+
- [ashid (TypeScript/JavaScript)](https://www.npmjs.com/package/ashid)
|
|
155
|
+
- [ashid (Kotlin/Java)](https://central.sonatype.com/artifact/agency.wilde/ashid)
|
|
156
|
+
- This Ruby gem
|
|
157
|
+
|
|
158
|
+
A test fixture in `test/fixtures/parity.json` verifies parity against IDs generated by the TS reference implementation.
|
|
159
|
+
|
|
160
|
+
## Comparison
|
|
161
|
+
|
|
162
|
+
| Feature | ashid | UUID | nanoid | ULID | SecureRandom.uuid |
|
|
163
|
+
|---|---|---|---|---|---|
|
|
164
|
+
| Type prefixes | Built-in | No | No | No | No |
|
|
165
|
+
| Time-sortable | Yes | No | No | Yes | No |
|
|
166
|
+
| Human-readable encoding | Crockford Base32 | Hex | Base64 | Base32 | Hex |
|
|
167
|
+
| Case-insensitive | Yes | Yes | No | No | Yes |
|
|
168
|
+
| Lookalike correction | Yes (I→1, O→0) | No | No | No | No |
|
|
169
|
+
| Double-click selectable | Yes | No (hyphens) | Yes | Yes | No |
|
|
170
|
+
| URL-safe | Yes | Needs encoding | Yes | Yes | Needs encoding |
|
|
171
|
+
| Stdlib only | Yes | (gem) | (gem) | (gem) | Yes |
|
|
172
|
+
|
|
173
|
+
## Inspired by
|
|
174
|
+
|
|
175
|
+
- [Stripe's ID format](https://stripe.com/docs/api) — the `sk_`, `pi_`, `cus_` prefix convention
|
|
176
|
+
- [Crockford Base32](https://www.crockford.com/base32.html) — human-friendly encoding
|
|
177
|
+
- [ULID](https://github.com/ulid/spec) — time-sortable IDs
|
|
178
|
+
- [TypeID](https://github.com/jetpack-io/typeid) — type-safe, K-sortable IDs
|
|
179
|
+
|
|
180
|
+
## Credits
|
|
181
|
+
|
|
182
|
+
Original ashid library by **Dathan Guiley** at [Wilde Agency](https://wilde.agency). Ruby port by **Dan Kozlowski**.
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom" # used by secure_random (added in Task 7)
|
|
4
|
+
|
|
5
|
+
module Ashid
|
|
6
|
+
module Encoder
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz".freeze
|
|
10
|
+
|
|
11
|
+
DECODE_MAP = begin
|
|
12
|
+
map = {}
|
|
13
|
+
ALPHABET.each_char.with_index do |c, i|
|
|
14
|
+
map[c] = i
|
|
15
|
+
map[c.upcase] = i
|
|
16
|
+
end
|
|
17
|
+
# Crockford lookalike corrections
|
|
18
|
+
map["O"] = map["o"] = 0
|
|
19
|
+
map["I"] = map["i"] = map["L"] = map["l"] = 1
|
|
20
|
+
map["U"] = map["u"] = 27 # maps to V's value
|
|
21
|
+
map.freeze
|
|
22
|
+
end
|
|
23
|
+
private_constant :DECODE_MAP
|
|
24
|
+
|
|
25
|
+
def encode(n, padded: false)
|
|
26
|
+
raise ArgumentError, "must be non-negative" if n < 0
|
|
27
|
+
|
|
28
|
+
str = encode_iterative(n)
|
|
29
|
+
padded ? str.rjust(13, "0") : str
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def decode(str)
|
|
33
|
+
raise InvalidEncodingError, "empty string" if str.nil? || str.empty?
|
|
34
|
+
|
|
35
|
+
str.each_char.reduce(0) do |acc, ch|
|
|
36
|
+
v = DECODE_MAP[ch]
|
|
37
|
+
raise InvalidEncodingError, "invalid character: #{ch.inspect}" if v.nil?
|
|
38
|
+
|
|
39
|
+
acc * 32 + v
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def secure_random(bits: 64)
|
|
44
|
+
raise ArgumentError, "bits must be a positive multiple of 8" unless bits.is_a?(Integer) && bits.positive? && (bits % 8).zero?
|
|
45
|
+
|
|
46
|
+
SecureRandom.bytes(bits / 8).bytes.reduce(0) { |acc, b| (acc << 8) | b }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def valid?(str)
|
|
50
|
+
return false unless str.is_a?(String)
|
|
51
|
+
|
|
52
|
+
decode(str)
|
|
53
|
+
true
|
|
54
|
+
rescue InvalidEncodingError
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def encode_iterative(n)
|
|
61
|
+
return "0" if n.zero?
|
|
62
|
+
|
|
63
|
+
chars = []
|
|
64
|
+
while n > 0
|
|
65
|
+
chars.unshift(ALPHABET[n % 32])
|
|
66
|
+
n /= 32
|
|
67
|
+
end
|
|
68
|
+
chars.join
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/ashid/error.rb
ADDED
data/lib/ashid.rb
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ashid/version"
|
|
4
|
+
require_relative "ashid/error"
|
|
5
|
+
require_relative "ashid/encoder"
|
|
6
|
+
|
|
7
|
+
module Ashid
|
|
8
|
+
extend self
|
|
9
|
+
|
|
10
|
+
MAX_TIMESTAMP = 35_184_372_088_831
|
|
11
|
+
TIMESTAMP_ENCODED_LENGTH = 9
|
|
12
|
+
RANDOM_ENCODED_LENGTH = 13
|
|
13
|
+
STANDARD_BASE_LENGTH = 22
|
|
14
|
+
ASHID4_BASE_LENGTH = 26
|
|
15
|
+
|
|
16
|
+
def generate(prefix = nil, time: current_time_ms, random: Encoder.secure_random(bits: 64))
|
|
17
|
+
raise ArgumentError, "time must be non-negative" if time < 0
|
|
18
|
+
raise ArgumentError, "time must not exceed #{MAX_TIMESTAMP}" if time > MAX_TIMESTAMP
|
|
19
|
+
raise ArgumentError, "random must be non-negative" if random < 0
|
|
20
|
+
|
|
21
|
+
normalized = normalize_prefix(prefix)
|
|
22
|
+
base = build_base_id(normalized, time, random)
|
|
23
|
+
"#{normalized}#{base}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def generate4(prefix = nil, random1: Encoder.secure_random(bits: 64), random2: Encoder.secure_random(bits: 64))
|
|
27
|
+
raise ArgumentError, "random1 must be non-negative" if random1 < 0
|
|
28
|
+
raise ArgumentError, "random2 must be non-negative" if random2 < 0
|
|
29
|
+
|
|
30
|
+
normalized = normalize_prefix(prefix)
|
|
31
|
+
base = "#{Encoder.encode(random1, padded: true)}#{Encoder.encode(random2, padded: true)}"
|
|
32
|
+
"#{normalized}#{base}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse(id)
|
|
36
|
+
raise InvalidIdError, "id cannot be nil" if id.nil?
|
|
37
|
+
raise InvalidIdError, "id must be a String" unless id.is_a?(String)
|
|
38
|
+
raise InvalidIdError, "id cannot be empty" if id.empty?
|
|
39
|
+
|
|
40
|
+
prefix_length = 0
|
|
41
|
+
has_delimiter = false
|
|
42
|
+
|
|
43
|
+
id.each_char do |ch|
|
|
44
|
+
if ch.match?(/[a-zA-Z]/)
|
|
45
|
+
prefix_length += 1
|
|
46
|
+
elsif (ch == "_" || ch == "-") && prefix_length > 0
|
|
47
|
+
prefix_length += 1
|
|
48
|
+
has_delimiter = true
|
|
49
|
+
break
|
|
50
|
+
else
|
|
51
|
+
prefix_length = 0
|
|
52
|
+
break
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
prefix = has_delimiter ? id[0, prefix_length].sub(/-\z/, "_") : ""
|
|
57
|
+
base_id = has_delimiter ? id[prefix_length..] : id
|
|
58
|
+
|
|
59
|
+
raise InvalidIdError, "id must have a base ID" if base_id.nil? || base_id.empty?
|
|
60
|
+
|
|
61
|
+
encoded_timestamp, encoded_random = split_base(base_id, has_delimiter)
|
|
62
|
+
|
|
63
|
+
{ prefix: prefix, encoded_timestamp: encoded_timestamp, encoded_random: encoded_random }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def prefix(id)
|
|
67
|
+
parse(id)[:prefix]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def timestamp(id)
|
|
71
|
+
Encoder.decode(parse(id)[:encoded_timestamp])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns a Time. Note: ms-precision via float division to seconds; sub-ms detail is lost
|
|
75
|
+
# but ms is our resolution anyway.
|
|
76
|
+
def time(id)
|
|
77
|
+
Time.at(timestamp(id) / 1000.0)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def random(id)
|
|
81
|
+
Encoder.decode(parse(id)[:encoded_random])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def random_bytes(id)
|
|
85
|
+
[random(id).to_s(16).rjust(16, "0")].pack("H*")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def valid?(id)
|
|
89
|
+
return false unless id.is_a?(String) && !id.empty?
|
|
90
|
+
|
|
91
|
+
parsed = parse(id)
|
|
92
|
+
if !parsed[:prefix].empty? && parsed[:prefix] !~ /\A[a-z0-9]+_\z/
|
|
93
|
+
return false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
Encoder.decode(parsed[:encoded_timestamp])
|
|
97
|
+
Encoder.decode(parsed[:encoded_random])
|
|
98
|
+
true
|
|
99
|
+
rescue Error
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def normalize(id)
|
|
104
|
+
parsed = parse(id)
|
|
105
|
+
norm_prefix = parsed[:prefix].empty? ? nil : parsed[:prefix].downcase.chomp("_")
|
|
106
|
+
time_value = Encoder.decode(parsed[:encoded_timestamp])
|
|
107
|
+
random_value = Encoder.decode(parsed[:encoded_random])
|
|
108
|
+
generate(norm_prefix, time: time_value, random: random_value)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def normalize_prefix(prefix)
|
|
114
|
+
return nil if prefix.nil? || prefix.empty?
|
|
115
|
+
|
|
116
|
+
cleaned = prefix.gsub(/[^a-zA-Z0-9]/, "").downcase
|
|
117
|
+
cleaned.empty? ? nil : "#{cleaned}_"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_base_id(normalized_prefix, time, random)
|
|
121
|
+
if normalized_prefix.nil?
|
|
122
|
+
# No prefix: fixed 22-char (timestamp padded to 9, random padded to 13)
|
|
123
|
+
time_part = Encoder.encode(time).rjust(TIMESTAMP_ENCODED_LENGTH, "0")
|
|
124
|
+
random_part = Encoder.encode(random, padded: true)
|
|
125
|
+
"#{time_part}#{random_part}"
|
|
126
|
+
elsif time > 0
|
|
127
|
+
# Variable length, padded random
|
|
128
|
+
"#{Encoder.encode(time)}#{Encoder.encode(random, padded: true)}"
|
|
129
|
+
else
|
|
130
|
+
# time == 0: omit timestamp, encode random unpadded
|
|
131
|
+
Encoder.encode(random)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def current_time_ms
|
|
136
|
+
(Process.clock_gettime(Process::CLOCK_REALTIME) * 1000).to_i
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def split_base(base_id, has_delimiter)
|
|
140
|
+
if has_delimiter
|
|
141
|
+
if base_id.length <= RANDOM_ENCODED_LENGTH
|
|
142
|
+
["0", base_id]
|
|
143
|
+
else
|
|
144
|
+
[base_id[0...-RANDOM_ENCODED_LENGTH], base_id[-RANDOM_ENCODED_LENGTH..]]
|
|
145
|
+
end
|
|
146
|
+
elsif base_id.length == ASHID4_BASE_LENGTH
|
|
147
|
+
[base_id[0, RANDOM_ENCODED_LENGTH], base_id[RANDOM_ENCODED_LENGTH..]]
|
|
148
|
+
elsif base_id.length == STANDARD_BASE_LENGTH
|
|
149
|
+
[base_id[0, TIMESTAMP_ENCODED_LENGTH], base_id[TIMESTAMP_ENCODED_LENGTH..]]
|
|
150
|
+
else
|
|
151
|
+
raise InvalidIdError,
|
|
152
|
+
"base ID must be #{STANDARD_BASE_LENGTH} or #{ASHID4_BASE_LENGTH} chars without delimiter (got #{base_id.length})"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ashid
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Dan Kozlowski
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-05-06 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.20'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.20'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
description: 'Ruby port of ashid: time-sortable unique IDs with optional type prefixes,
|
|
41
|
+
encoded in Crockford Base32. Wire-compatible with the TypeScript and Kotlin implementations.'
|
|
42
|
+
email:
|
|
43
|
+
- dakozlowski@gmail.com
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- CHANGELOG.md
|
|
49
|
+
- LICENSE.txt
|
|
50
|
+
- README.md
|
|
51
|
+
- lib/ashid.rb
|
|
52
|
+
- lib/ashid/encoder.rb
|
|
53
|
+
- lib/ashid/error.rb
|
|
54
|
+
- lib/ashid/version.rb
|
|
55
|
+
homepage: https://github.com/dankozlowski/ashid-ruby
|
|
56
|
+
licenses:
|
|
57
|
+
- MIT
|
|
58
|
+
metadata:
|
|
59
|
+
homepage_uri: https://github.com/dankozlowski/ashid-ruby
|
|
60
|
+
source_code_uri: https://github.com/dankozlowski/ashid-ruby
|
|
61
|
+
changelog_uri: https://github.com/dankozlowski/ashid-ruby/blob/main/CHANGELOG.md
|
|
62
|
+
bug_tracker_uri: https://github.com/dankozlowski/ashid-ruby/issues
|
|
63
|
+
rubygems_mfa_required: 'true'
|
|
64
|
+
rdoc_options: []
|
|
65
|
+
require_paths:
|
|
66
|
+
- lib
|
|
67
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '3.0'
|
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '0'
|
|
77
|
+
requirements: []
|
|
78
|
+
rubygems_version: 3.6.2
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: Time-sortable unique identifiers with type prefixes
|
|
81
|
+
test_files: []
|