├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .standard.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── tou.rb └── tou │ ├── generator.rb │ └── version.rb ├── test ├── test_helper.rb └── tou_test.rb └── tou.gemspec /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - push 5 | 6 | env: 7 | BUNDLE_PATH: vendor/bundle 8 | 9 | jobs: 10 | test: 11 | name: Tests 12 | runs-on: ubuntu-22.04 13 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '2.7' 18 | - '3.2' 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - name: Setup Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | - name: "Tests and Lint" 28 | run: bundle exec rake 29 | 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock 10 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 2.7 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at me@julik.nl. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in tou.gemspec 4 | gemspec 5 | 6 | gem "rake", "~> 12.0" 7 | gem "minitest", "~> 5.0" 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Julik Tarkhanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tou 2 | 3 | `Tou`, short for **Time-Ordered-UUID**, is a time-ordered unique identifier scheme. It produces bytes which are compatible with UUIDv4 - the most common UUID format at the moment. While it looks identical to a usual UUIDv4 and will be accepted by all the systems that accept those UUIDs, it has a number of useful properties: 4 | 5 | * It starts with the current number of microseconds, packed in network byte order. This means that the UUIDs will sort by byte value in time-ascending order 6 | * It uses 7 bytes of the whole number of microseconds out of 8, giving enough capacity until year 4253 7 | * The rest of the UUID is filled with random bits 8 | * The UUID still has the correct version (4) and variant (1) to be recognized as a UUIDv4 9 | 10 | The usage of such UUIDs has some neat properties: 11 | 12 | * They sort better in databases using bytes for UUID storage. Iterative SELECTs on large datasets will be much more pleasant. 13 | * They will likely compose into more efficient B-trees in database indexes 14 | * They sort chronologically 15 | * The timestamp can be reconstructed from the UUID, and stays relatively precise 16 | * Any system that accepts UUIDv4 identifiers will also accept Tou identifiers 17 | * ...which means that you do not need, say, Postgres extensions to use UUIDv7 18 | 19 | ## Usage 20 | 21 | ```ruby 22 | Tou.uuid #=> "061a417b-0e60-4009-9822-72d241ef27d6" 23 | ``` 24 | 25 | Not much to it, really. 26 | 27 | ## Spec, layout in storage/memory 28 | 29 | The Tou is laid out as follows (in its byte representation): 30 | 31 | ``` 32 | 0 1 2 3 33 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 34 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 35 | | mus | 36 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 37 | | mus | ver | mus | random | 38 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 39 | |var| random | 40 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 41 | | random | 42 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 43 | 44 | 45 | mus: 46 | 56-bit big-endian unsigned number of the Unix Epoch timestamp in 47 | microseconds. Occupies 48 bits (0 through 47 in octets 0-5) and 48 | 4 bits (53 through 57 in octet 6). 49 | 50 | ver: 51 | The 4-bit version field as defined by Section 4.2 of RFC9562, 52 | set to 0b0100 (4). Occupies bits 48 through 52. 53 | 54 | random: 55 | The 70 bits of pseudorandom data to provide uniqueness as 56 | per Section 6.9 of RFC9562 and/or an optional counter to guarantee 57 | additional monotonicity as per Section 6.2 of RFC9562. 58 | Occupies bits 49 through 63 and 66 through 127 of 59 | octets 7 to 15 60 | 61 | var: 62 | The 2-bit variant field as defined by Section 4.1 of RFC9562, 63 | set to 0b10. Occupies bits 64 and 65 of octet 8. 64 | 65 | ``` 66 | 67 | Compare this to the UUIDv4 layout: 68 | 69 | ``` 70 | 0 1 2 3 71 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 72 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 73 | | random_a | 74 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 75 | | random_a | ver | random_b | 76 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 77 | |var| random_c | 78 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 79 | | random_c | 80 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 81 | ``` 82 | 83 | ## Installation 84 | 85 | Add this line to your application's Gemfile: 86 | 87 | ```ruby 88 | gem 'tou' 89 | ``` 90 | 91 | And then execute: 92 | 93 | $ bundle install 94 | 95 | Or install it yourself as: 96 | 97 | $ gem install tou 98 | 99 | 100 | ## Development 101 | 102 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 103 | 104 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 105 | 106 | ## Contributing 107 | 108 | Bug reports and pull requests are welcome on GitHub at https://github.com/cheddar-me/tou. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/cheddar-me/tou/blob/master/CODE_OF_CONDUCT.md). 109 | 110 | 111 | ## License 112 | 113 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 114 | 115 | ## Code of Conduct 116 | 117 | Everyone interacting in the Tou project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/cheddar-me/tou/blob/master/CODE_OF_CONDUCT.md). 118 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | end 9 | 10 | task default: :test 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "tou" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/tou.rb: -------------------------------------------------------------------------------- 1 | require "tou/version" 2 | require "securerandom" 3 | 4 | # A generator for time-ordered UUIDs 5 | module Tou 6 | autoload :VERSION, __dir__ + "/tou/version.rb" 7 | autoload :Generator, __dir__ + "/tou/generator.rb" 8 | 9 | # Generates the bag of 16 bytes with UUID in binary form. This is not the 10 | # canonical representation but can be converted into one. 11 | # 12 | # @param random[#bytes] Source of randomness. Normally SecureRandom will be used, but it can be 13 | # replaced by a mock or a `Random` object with a known seed, for testing or speed. 14 | # @param time[#to_f] A time value that is convertible to a floating-point number of seconds since epoch 15 | # @return [String] in binary encoding 16 | def self.uuid_bytes(random: SecureRandom, time: Time.now) 17 | # Use microseconds for the timestamp 18 | epoch_micros = (time.to_f * 1_000_000).round 19 | # Encode an 8-byte unsigned big-endian uint, and skip the first byte since it's 0, so we're left with 7 bytes. 20 | # This gives us sufficient timestamp resolution to last us into year 21 | # This will limit our timestamp to the year 4307. 22 | # Q> : 64 bit unsigned big-endian int 23 | # We want more significant bytes first for better sorting in Postgres 24 | epoch_micros_unsigned_uint_bytes = [epoch_micros].pack("Q>")[1..] 25 | ts_bytes = epoch_micros_unsigned_uint_bytes 26 | 27 | # Use the remaining bytes for randomness 28 | byte_str = ts_bytes + random.bytes(16 - ts_bytes.bytesize) 29 | 30 | # This last part encodes the version, the timecode and the variant. 31 | # To prevent we have a variant, 4 bits of random and then the timecode I move up the timecode 4 bits. 32 | # We end up with: 4bit variant - 8bit timecode - 4bit random from original byte string, hence 2 bytes in total. 33 | # 34 | # V4 random UUIDs use 4 bits to indicate a version and another 2-3 bits to indicate a variant. 35 | # Most V4s (including these ones) are variant 1, which is 2 bits. 36 | version = "4" # version 4, 4 bits. 37 | timepart = ts_bytes[-1].unpack1("H*") # last byte of timecode (ts_bytes) as hex, 8 bits. 38 | rest = byte_str[7].unpack1("h") # last 4 bits of 7th byte as hex. 39 | 40 | # Our uuid with encoded timestamp will look something like this 16 bytes in total: 41 | # ts = timestamp byte 42 | # rnd = random byte 43 | # |ts| ts, ts, ts, ts, ts, version(4bits) + first half ts(4bits), last half of ts(4bits) + rnd, variant(2bits) + rnd, rnd, rnd, rnd, rnd, rnd, rnd, rnd] 44 | byte_str_part_with_time_and_variant = [version + timepart + rest].pack("H*") 45 | 46 | byte_str.setbyte(6, byte_str_part_with_time_and_variant.getbyte(0)) 47 | byte_str.setbyte(7, byte_str_part_with_time_and_variant.getbyte(1)) 48 | byte_str.setbyte(8, (byte_str.getbyte(8) & 0x3f) | 0x80) # variant 1 (10 binary) 49 | 50 | byte_str 51 | end 52 | 53 | # Generates the bag of 16 bytes with UUID in binary form. This the 54 | # canonical representation but can be converted into one. 55 | # 56 | # @param random[#bytes] Source of randomness. Normally SecureRandom will be used, but it can be 57 | # replaced by a mock or a `Random` object with a known seed, for testing or speed. 58 | # @param time[#to_f] A time value that is convertible to a floating-point number of seconds since epoch 59 | # @return [String] in binary encoding 60 | def self.uuid(**params_for_uuid_bytes) 61 | # N : 32 bit unsigned int 62 | # n : 16 bit unsigned int 63 | # This will separate the whole random bytestring into a couple groups 64 | # (as various sized integers) that match the uuid group size 65 | # so [32bit int, 16bit int, 16bit int, 16bit int, 16bit int, 32bit int] 66 | # Which then can be shown in hex into the string literal to make up the final uuid. 67 | ary = uuid_bytes(**params_for_uuid_bytes).unpack("NnnnnN") 68 | # format in hex notation with dashes 69 | "%08x-%04x-%04x-%04x-%04x%08x" % ary 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/tou/generator.rb: -------------------------------------------------------------------------------- 1 | module Tou 2 | # When included into an ActiveRecord, will generate a TOU and prefill it 3 | # into the "id" attribute if it is blank. Add this module to your records 4 | # to set their IDs using Tou. 5 | module Generator 6 | def self.included(into) 7 | into.before_validation :generate_and_write_touid! 8 | super 9 | end 10 | 11 | def generate_and_write_touid! 12 | write_attribute("id", Tou.uuid) if read_attribute("id").blank? 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/tou/version.rb: -------------------------------------------------------------------------------- 1 | module Tou 2 | VERSION = "0.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | require "tou" 3 | 4 | require "minitest/autorun" 5 | -------------------------------------------------------------------------------- /test/tou_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TouTest < Minitest::Test 4 | def test_that_it_has_a_version_number 5 | refute_nil ::Tou::VERSION 6 | end 7 | 8 | def test_generates_uuids_with_known_time_and_entropy_source 9 | rng = Random.new(42) 10 | t = Time.utc(2024, 6, 7) 11 | uids = 10.times.map { Tou.uuid(random: rng, time: t) } 12 | ref = [ 13 | "061a417b-0e60-4006-9ce1-5fb33deacb5c", 14 | "061a417b-0e60-400e-95f5-2e6af463bb47", 15 | "061a417b-0e60-400c-ae41-99142ccb9866", 16 | "061a417b-0e60-4009-9822-72d241ef27d6", 17 | "061a417b-0e60-400a-91de-0eca55917557", 18 | "061a417b-0e60-4004-ad6d-5563ace29967", 19 | "061a417b-0e60-4007-be44-b582a0a0a695", 20 | "061a417b-0e60-4004-bd70-0e01034cf857", 21 | "061a417b-0e60-400b-b51a-d59dfd44f025", 22 | "061a417b-0e60-4001-8933-00bf148c2ebb" 23 | ] 24 | assert_equal ref, uids 25 | end 26 | 27 | def test_generates_uuids_with_correct_format_and_of_variant_4 28 | uids = 10.times.map { Tou.uuid } 29 | uids.each do |uid| 30 | # Assert 4 at the nibble position explicitly 31 | assert_match(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/, uid) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /tou.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/tou/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "tou" 5 | spec.version = Tou::VERSION 6 | spec.authors = ["Sebastian van Hesteren", "Julik Tarkhanov"] 7 | spec.email = ["me@julik.nl", "sebastian@cheddar.me"] 8 | 9 | spec.summary = "Time-ordered UUIDv4" 10 | spec.description = "Time-ordered UUIDv4" 11 | spec.homepage = "https://github.com/cheddar-me/tou" 12 | spec.license = "MIT" 13 | spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") 14 | 15 | # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" 16 | 17 | spec.metadata["homepage_uri"] = spec.homepage 18 | spec.metadata["source_code_uri"] = "https://github.com/cheddar-me/tou" 19 | # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." 20 | 21 | # Specify which files should be added to the gem when it is released. 22 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 23 | spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do 24 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 25 | end 26 | spec.bindir = "exe" 27 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 28 | spec.require_paths = ["lib"] 29 | spec.add_development_dependency "standard" 30 | end 31 | --------------------------------------------------------------------------------