├── .gitignore ├── .editorconfig ├── config └── sus.rb ├── lib ├── relaxo │ ├── version.rb │ ├── logger.rb │ ├── dataset.rb │ ├── changeset.rb │ ├── directory.rb │ └── database.rb └── relaxo.rb ├── .github └── workflows │ ├── rubocop.yaml │ ├── documentation-coverage.yaml │ ├── test-external.yaml │ ├── test.yaml │ ├── documentation.yaml │ └── test-coverage.yaml ├── gems.rb ├── test ├── relaxo.rb └── relaxo │ ├── enumeration.rb │ ├── concurrency.rb │ ├── changeset.rb │ └── database.rb ├── relaxo.gemspec ├── fixtures └── relaxo │ └── test_records.rb ├── .rubocop.yml ├── license.md ├── release.cert ├── benchmarks └── performance.rb ├── readme.md └── logo.svg /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | 7 | /tmp 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | require "covered/sus" 7 | include Covered::Sus 8 | -------------------------------------------------------------------------------- /lib/relaxo/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2012-2025, by Samuel Williams. 5 | 6 | module Relaxo 7 | VERSION = "1.8.0" 8 | end 9 | -------------------------------------------------------------------------------- /lib/relaxo/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2025, by Samuel Williams. 5 | 6 | require "console" 7 | 8 | module Relaxo 9 | extend Console 10 | end 11 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ruby 20 | bundler-cache: true 21 | 22 | - name: Run RuboCop 23 | timeout-minutes: 10 24 | run: bundle exec rubocop 25 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2012-2025, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gemspec 9 | 10 | group :maintenance, optional: true do 11 | gem "bake-gem" 12 | gem "bake-modernize" 13 | 14 | gem "utopia-project" 15 | end 16 | 17 | group :test do 18 | gem "sus" 19 | gem "covered" 20 | gem "decode" 21 | gem "rubocop" 22 | 23 | gem "bake-test" 24 | gem "bake-test-external" 25 | 26 | gem "msgpack" 27 | end 28 | -------------------------------------------------------------------------------- /.github/workflows/documentation-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | validate: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "3.4" 21 | bundler-cache: true 22 | 23 | - name: Validate coverage 24 | timeout-minutes: 5 25 | run: bundle exec bake decode:index:coverage lib 26 | -------------------------------------------------------------------------------- /test/relaxo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "relaxo/test_records" 7 | 8 | describe Relaxo do 9 | with ".connect" do 10 | include Relaxo::TemporaryDatabase 11 | 12 | it "can connect to a new database" do 13 | expect(database).to be_a Relaxo::Database 14 | expect(database.branch).to (be == "main").or(be == "master") 15 | end 16 | 17 | it "can connect to a new database with an alternative branch name" do 18 | Relaxo.connect(database_path, branch: "development") 19 | 20 | expect(database).to be_a Relaxo::Database 21 | expect(database.branch).to be == "development" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.github/workflows/test-external.yaml: -------------------------------------------------------------------------------- 1 | name: Test External 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu 20 | - macos 21 | 22 | ruby: 23 | - "3.1" 24 | - "3.2" 25 | - "3.3" 26 | - "3.4" 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{matrix.ruby}} 33 | bundler-cache: true 34 | 35 | - name: Run tests 36 | timeout-minutes: 10 37 | run: bundle exec bake test:external 38 | -------------------------------------------------------------------------------- /test/relaxo/enumeration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | 6 | require "relaxo/test_records" 7 | 8 | describe Relaxo::Dataset do 9 | include_context Relaxo::TestRecords 10 | 11 | it "should enumerate all documents" do 12 | records = [] 13 | 14 | database.current do |dataset| 15 | records = dataset.each(prefix).to_a 16 | end 17 | 18 | expect(records.count).to be == 20 19 | end 20 | end 21 | 22 | describe Relaxo::Changeset do 23 | include_context Relaxo::TestRecords 24 | 25 | it "should enumerate all documents" do 26 | records = [] 27 | 28 | database.commit(message: "Testing Enumeration") do |dataset| 29 | records = dataset.each(prefix).to_a 30 | end 31 | 32 | expect(records.count).to be == 20 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /relaxo.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/relaxo/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "relaxo" 7 | spec.version = Relaxo::VERSION 8 | 9 | spec.summary = "Relaxo is versioned document database built on top of git." 10 | spec.authors = ["Samuel Williams", "Huba Nagy", "Olle Jonsson"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ["release.cert"] 14 | spec.signing_key = File.expand_path("~/.gem/release.pem") 15 | 16 | spec.homepage = "https://github.com/ioquatix/relaxo" 17 | 18 | spec.metadata = { 19 | "funding_uri" => "https://github.com/sponsors/ioquatix/", 20 | "source_code_uri" => "https://github.com/ioquatix/relaxo.git", 21 | } 22 | 23 | spec.files = Dir.glob(["{lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) 24 | 25 | spec.required_ruby_version = ">= 3.1" 26 | 27 | spec.add_dependency "console" 28 | spec.add_dependency "rugged" 29 | end 30 | -------------------------------------------------------------------------------- /test/relaxo/concurrency.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | 6 | require "relaxo/test_records" 7 | 8 | describe Relaxo::Changeset do 9 | include_context Relaxo::TestRecords 10 | 11 | it "should detect conflicts" do 12 | events = [] 13 | 14 | alice = Fiber.new do 15 | database.commit(message: "Alice Data") do |changeset| 16 | events << :alice 17 | 18 | object = changeset.append("sample-data-1") 19 | changeset.write("conflict-path", object) 20 | 21 | Fiber.yield 22 | end 23 | end 24 | 25 | bob = Fiber.new do 26 | database.commit(message: "Bob Data") do |changeset| 27 | events << :bob 28 | 29 | object = changeset.append("sample-data-1") 30 | changeset.write("conflict-path", object) 31 | 32 | Fiber.yield 33 | end 34 | end 35 | 36 | alice.resume 37 | bob.resume 38 | alice.resume 39 | bob.resume 40 | 41 | expect(events).to be == [:alice, :bob, :bob] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /fixtures/relaxo/test_records.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | 6 | require "relaxo" 7 | require "tmpdir" 8 | 9 | module Relaxo 10 | TemporaryDatabase = Sus::Shared("temporary database") do 11 | def around 12 | Dir.mktmpdir do |directory| 13 | @root = directory 14 | super 15 | end 16 | end 17 | 18 | let(:database_path) {@root} 19 | let(:database) {Relaxo.connect(database_path)} 20 | end 21 | 22 | TestRecords = Sus::Shared("test records") do 23 | include_context Relaxo::TemporaryDatabase 24 | 25 | let(:prefix) {"records"} 26 | 27 | def before 28 | super 29 | 30 | database.commit(message: "Create Sample Data") do |dataset| 31 | 20.times do |i| 32 | object = dataset.append("good-#{i}") 33 | dataset.write("#{prefix}/#{i}", object) 34 | end 35 | 36 | 10.times do |i| 37 | object = dataset.append("bad-#{i}") 38 | dataset.write("#{prefix}/subdirectory/#{i}", object) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Layout/IndentationStyle: 5 | Enabled: true 6 | EnforcedStyle: tabs 7 | 8 | Layout/InitialIndentation: 9 | Enabled: true 10 | 11 | Layout/IndentationWidth: 12 | Enabled: true 13 | Width: 1 14 | 15 | Layout/IndentationConsistency: 16 | Enabled: true 17 | EnforcedStyle: normal 18 | 19 | Layout/BlockAlignment: 20 | Enabled: true 21 | 22 | Layout/EndAlignment: 23 | Enabled: true 24 | EnforcedStyleAlignWith: start_of_line 25 | 26 | Layout/BeginEndAlignment: 27 | Enabled: true 28 | EnforcedStyleAlignWith: start_of_line 29 | 30 | Layout/ElseAlignment: 31 | Enabled: true 32 | 33 | Layout/DefEndAlignment: 34 | Enabled: true 35 | 36 | Layout/CaseIndentation: 37 | Enabled: true 38 | 39 | Layout/CommentIndentation: 40 | Enabled: true 41 | 42 | Layout/EmptyLinesAroundClassBody: 43 | Enabled: true 44 | 45 | Layout/EmptyLinesAroundModuleBody: 46 | Enabled: true 47 | 48 | Style/FrozenStringLiteralComment: 49 | Enabled: true 50 | 51 | Style/StringLiterals: 52 | Enabled: true 53 | EnforcedStyle: double_quotes 54 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.1" 25 | - "3.2" 26 | - "3.3" 27 | - "3.4" 28 | 29 | experimental: [false] 30 | 31 | include: 32 | - os: ubuntu 33 | ruby: truffleruby 34 | experimental: true 35 | - os: ubuntu 36 | ruby: jruby 37 | experimental: true 38 | - os: ubuntu 39 | ruby: head 40 | experimental: true 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: ${{matrix.ruby}} 47 | bundler-cache: true 48 | 49 | - name: Run tests 50 | timeout-minutes: 10 51 | run: bundle exec bake test 52 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2012-2025, by Samuel Williams. 4 | Copyright, 2017-2018, by Huba Nagy. 5 | Copyright, 2020, by Olle Jonsson. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /lib/relaxo/dataset.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2012-2025, by Samuel Williams. 5 | 6 | require "rugged" 7 | 8 | require_relative "directory" 9 | 10 | module Relaxo 11 | class Dataset 12 | def initialize(repository, tree) 13 | @repository = repository 14 | @tree = tree 15 | 16 | @directories = {} 17 | end 18 | 19 | def read(path) 20 | if entry = @tree.path(path) and entry[:type] == :blob and oid = entry[:oid] 21 | @repository.read(oid) 22 | end 23 | rescue Rugged::TreeError 24 | return nil 25 | end 26 | 27 | alias [] read 28 | 29 | def file? 30 | read(path) 31 | end 32 | 33 | def exist?(path) 34 | read(path) or directory?(path) 35 | end 36 | 37 | def directory?(path) 38 | @directories.key?(path) or @tree.path(path)[:type] == :tree 39 | rescue Rugged::TreeError 40 | return false 41 | end 42 | 43 | def each(path = "", &block) 44 | return to_enum(:each, path) unless block_given? 45 | 46 | directory = fetch_directory(path) 47 | 48 | directory.each(&block) 49 | end 50 | 51 | protected 52 | 53 | def fetch_directory(path) 54 | @directories[path] ||= Directory.new(@repository, @tree, path) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | CONSOLE_OUTPUT: XTerm 21 | BUNDLE_WITH: maintenance 22 | 23 | jobs: 24 | generate: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: "3.4" 33 | bundler-cache: true 34 | 35 | - name: Installing packages 36 | run: sudo apt-get install wget 37 | 38 | - name: Generate documentation 39 | timeout-minutes: 5 40 | run: bundle exec bake utopia:project:static --force no 41 | 42 | - name: Upload documentation artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{steps.deployment.outputs.page_url}} 53 | 54 | needs: generate 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | test: 14 | name: ${{matrix.ruby}} on ${{matrix.os}} 15 | runs-on: ${{matrix.os}}-latest 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.4" 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{matrix.ruby}} 31 | bundler-cache: true 32 | 33 | - name: Run tests 34 | timeout-minutes: 5 35 | run: bundle exec bake test 36 | 37 | - uses: actions/upload-artifact@v4 38 | with: 39 | include-hidden-files: true 40 | if-no-files-found: error 41 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 42 | path: .covered.db 43 | 44 | validate: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: ruby/setup-ruby@v1 51 | with: 52 | ruby-version: "3.4" 53 | bundler-cache: true 54 | 55 | - uses: actions/download-artifact@v4 56 | 57 | - name: Validate coverage 58 | timeout-minutes: 5 59 | run: bundle exec bake covered:validate --paths */.covered.db \; 60 | -------------------------------------------------------------------------------- /lib/relaxo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2012-2025, by Samuel Williams. 5 | 6 | require "relaxo/database" 7 | 8 | require "etc" 9 | require "socket" 10 | 11 | module Relaxo 12 | DEFAULT_BRANCH = "main".freeze 13 | 14 | def self.connect(path, branch: nil, sync: nil, create: true, **metadata) 15 | if !File.exist?(path) || create 16 | repository = Rugged::Repository.init_at(path, true) 17 | 18 | if branch 19 | repository.head = "refs/heads/#{branch}" 20 | end 21 | 22 | if sync || ENV["RELAXO_SYNC"] 23 | repository.config["core.fsyncObjectFiles"] = true 24 | end 25 | else 26 | repository = Rugged::Repository.new(path) 27 | end 28 | 29 | # Automatically detect the current branch if `branch` is not provided: 30 | branch ||= self.default_branch(repository) 31 | 32 | database = Database.new(path, branch, metadata) 33 | 34 | if config = database.config 35 | unless config["user.name"] 36 | login = Etc.getpwuid 37 | hostname = Socket.gethostname 38 | 39 | if login 40 | config["user.name"] = login.name 41 | config["user.email"] = "#{login.name}@#{hostname}" 42 | end 43 | end 44 | end 45 | 46 | return database 47 | end 48 | 49 | private 50 | 51 | # Detect the default branch of the repository, taking into account unborn branches. 52 | def self.default_branch(repository) 53 | if head = repository.references["HEAD"] 54 | if target_id = head.target_id 55 | return target_id.sub(/^refs\/heads\//, "") 56 | end 57 | end 58 | 59 | return DEFAULT_BRANCH 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/relaxo/changeset.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | 6 | require "relaxo/test_records" 7 | 8 | describe Relaxo::Changeset do 9 | include_context Relaxo::TestRecords 10 | 11 | it "should enumerate all documents including writes" do 12 | records = [] 13 | 14 | database.commit(message: "Testing Enumeration") do |dataset| 15 | 5.times do |i| 16 | object = dataset.append("extra-#{i}") 17 | dataset.write("#{prefix}/extra-#{i}", object) 18 | end 19 | 20 | expect(dataset.exist?("#{prefix}/extra-0")).to be_truthy 21 | 22 | records = dataset.each(prefix).to_a 23 | end 24 | 25 | expect(records.count).to be == 25 26 | end 27 | 28 | it "should enumerate all documents excluding deletes" do 29 | records = database.commit(message: "Testing Enumeration") do |dataset| 30 | 5.times do |i| 31 | dataset.delete("#{prefix}/#{i}") 32 | end 33 | 34 | expect(dataset.exist?("#{prefix}/0")).to be_falsey 35 | 36 | dataset.each(prefix).to_a 37 | end 38 | 39 | expect(records.count).to be == 15 40 | end 41 | 42 | let(:author) do 43 | {name: "Testing McTestface", email: "testing@testing.com"} 44 | end 45 | 46 | it "can use specified author" do 47 | database.commit(message: "Testing Enumeration", author: author) do |dataset| 48 | object = dataset.append("Hello World!") 49 | dataset.write("hello.txt", object) 50 | end 51 | 52 | commit = database.head.target 53 | expect(commit.author).to have_keys( 54 | name: be == "Testing McTestface", 55 | email: be == "testing@testing.com", 56 | ) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/relaxo/changeset.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2012-2025, by Samuel Williams. 5 | 6 | require_relative "dataset" 7 | 8 | module Relaxo 9 | class Changeset < Dataset 10 | def initialize(repository, tree) 11 | super 12 | 13 | @changes = {} 14 | @directories = {} 15 | end 16 | 17 | attr :ref 18 | attr :changes 19 | 20 | def changes? 21 | @changes.any? 22 | end 23 | 24 | def read(path) 25 | if update = @changes[path] 26 | if update[:action] != :remove 27 | @repository.read(update[:oid]) 28 | end 29 | else 30 | super 31 | end 32 | end 33 | 34 | def append(data, type = :blob) 35 | oid = @repository.write(data, type) 36 | 37 | return @repository.read(oid) 38 | end 39 | 40 | def write(path, object, mode = 0100644) 41 | root, _, name = path.rpartition("/") 42 | 43 | entry = @changes[path] = { 44 | action: :upsert, 45 | oid: object.oid, 46 | object: object, 47 | filemode: mode, 48 | path: path, 49 | root: root, 50 | name: name, 51 | } 52 | 53 | fetch_directory(root).insert(entry) 54 | 55 | return entry 56 | end 57 | 58 | alias []= write 59 | 60 | def delete(path) 61 | root, _, name = path.rpartition("/") 62 | 63 | entry = @changes[path] = { 64 | action: :remove, 65 | path: path, 66 | root: root, 67 | name: name, 68 | } 69 | 70 | fetch_directory(root).delete(entry) 71 | 72 | return entry 73 | end 74 | 75 | def abort! 76 | throw :abort 77 | end 78 | 79 | def write_tree 80 | @tree.update(@changes.values) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /release.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK 4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz 5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd 6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj 7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB 8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW 10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE 11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN 12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss 13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn 14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM 15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW 16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O 17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs 18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs 19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE 20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl 21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ 22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws 24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP 25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt 26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /benchmarks/performance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | 6 | require "benchmark/ips" if ENV["BENCHMARK"] 7 | require "ruby-prof" if ENV["PROFILE"] 8 | require "flamegraph" if ENV["FLAMEGRAPH"] 9 | 10 | describe "Relaxo Performance" do 11 | let(:database_path) {File.join(__dir__, "test")} 12 | let(:database) {Relaxo.connect(database_path)} 13 | 14 | if defined? Benchmark 15 | def benchmark(name = nil) 16 | Benchmark.ips do |benchmark| 17 | # Collect more data for benchmark: 18 | benchmark.time = 20 19 | benchmark.warmup = 10 20 | 21 | benchmark.report(name) do |i| 22 | yield i 23 | end 24 | 25 | benchmark.compare! 26 | end 27 | end 28 | elsif defined? RubyProf 29 | def benchmark(name) 30 | result = RubyProf.profile do 31 | yield 1000 32 | end 33 | 34 | #result.eliminate_methods!([/^((?!Utopia).)*$/]) 35 | printer = RubyProf::FlatPrinter.new(result) 36 | printer.print($stderr, min_percent: 1.0) 37 | 38 | printer = RubyProf::GraphHtmlPrinter.new(result) 39 | filename = name.gsub("/", "_") + ".html" 40 | File.open(filename, "w") do |file| 41 | printer.print(file) 42 | end 43 | end 44 | elsif defined? Flamegraph 45 | def benchmark(name) 46 | filename = name.gsub("/", "_") + ".html" 47 | Flamegraph.generate(filename) do 48 | yield 1 49 | end 50 | end 51 | else 52 | def benchmark(name) 53 | yield 1 54 | end 55 | end 56 | 57 | before(:each) do 58 | FileUtils.rm_rf(database_path) 59 | end 60 | 61 | it "single transaction should be fast" do 62 | benchmark("single") do |iterations| 63 | database.commit(message: "Some Documents") do |dataset| 64 | iterations.times do |i| 65 | object = dataset.append("good-#{i}") 66 | dataset.write("#{i%100}/#{i}", object) 67 | end 68 | end 69 | end 70 | end 71 | 72 | it "multiple transactions should be fast" do 73 | benchmark("multiple") do |iterations| 74 | iterations.times do |i| 75 | database.commit(message: "Some Documents") do |dataset| 76 | object = dataset.append("good-#{i}") 77 | dataset.write("#{i%100}/#{i}", object) 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/relaxo/directory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2017-2025, by Samuel Williams. 5 | 6 | require "rugged" 7 | 8 | module Relaxo 9 | class Directory 10 | def initialize(repository, root_tree, path) 11 | @repository = repository 12 | 13 | # The root tree, which path is relative to: 14 | @root_tree = root_tree 15 | 16 | # The entry and tree for the directory itself: 17 | @entry = nil 18 | @tree = nil 19 | 20 | @path = path 21 | 22 | @entries = nil 23 | @changes = {} 24 | end 25 | 26 | def freeze 27 | @changes.freeze 28 | 29 | super 30 | end 31 | 32 | def entries 33 | @entries ||= load_entries! 34 | end 35 | 36 | def each(&block) 37 | return to_enum(:each) unless block_given? 38 | 39 | entries.each do |entry| 40 | entry[:object] ||= @repository.read(entry[:oid]) 41 | 42 | yield entry[:name], entry[:object] 43 | end 44 | end 45 | 46 | def each_entry(&block) 47 | return to_enum(:each_entry) unless block_given? 48 | 49 | entries.each(&block) 50 | end 51 | 52 | def insert(entry) 53 | _, _, name = entry[:name].rpartition("/") 54 | 55 | @changes[name] = entry 56 | 57 | # Blow away the cache: 58 | @entries = nil 59 | end 60 | 61 | def delete(entry) 62 | _, _, name = entry[:name].rpartition("/") 63 | 64 | @changes[name] = nil 65 | 66 | # Blow away the cache: 67 | @entries = nil 68 | end 69 | 70 | private 71 | 72 | # Look up the entry for the given directory `@path`: 73 | def fetch_entry 74 | @entry ||= @root_tree.path(@path) 75 | end 76 | 77 | # Load the directory tree for the given `@path`: 78 | def fetch_tree 79 | @tree ||= Rugged::Tree.new(@repository, fetch_entry[:oid]) 80 | rescue Rugged::TreeError 81 | return nil 82 | end 83 | 84 | # Load the entries from the tree, applying any changes. 85 | def load_entries! 86 | entries = @changes.dup 87 | 88 | if tree = fetch_tree 89 | tree.each_blob do |entry| 90 | unless entries.key? entry[:name] 91 | entries[entry[:name]] = entry 92 | end 93 | end 94 | end 95 | 96 | return entries.values.compact.sort_by{|entry| entry[:name]} 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/relaxo/database.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2012-2025, by Samuel Williams. 5 | # Copyright, 2017, by Huba Nagy. 6 | 7 | require "relaxo" 8 | require "relaxo/test_records" 9 | 10 | describe Relaxo::Database do 11 | include_context Relaxo::TemporaryDatabase 12 | 13 | let(:document_path) {"test/document.json"} 14 | let(:sample_json) {"[1, 2, 3]"} 15 | 16 | it "should be initially empty" do 17 | expect(database).to be(:empty?) 18 | end 19 | 20 | it "prepares user details in config" do 21 | expect(database.config.to_hash).to have_keys( 22 | "user.name", "user.email" 23 | ) 24 | end 25 | 26 | it "can clear database" do 27 | database.clear! 28 | expect(database).to be(:empty?) 29 | end 30 | 31 | it "should not be empty with one document" do 32 | database.commit(message: "Create test document") do |dataset| 33 | oid = dataset.append(sample_json) 34 | dataset.write(document_path, oid) 35 | end 36 | 37 | expect(database).not.to be(:empty?) 38 | end 39 | 40 | it "should be able to clear the database" do 41 | database.commit(message: "Create test document") do |dataset| 42 | oid = dataset.append(sample_json) 43 | dataset.write(document_path, oid) 44 | end 45 | 46 | expect(database).not.to be(:empty?) 47 | 48 | database.clear! 49 | 50 | expect(database).to be(:empty?) 51 | end 52 | 53 | it "should have metadata" do 54 | expect(database.metadata).to be == {} 55 | end 56 | 57 | it "should create a document" do 58 | database.commit(message: "Create test document") do |dataset| 59 | oid = dataset.append(sample_json) 60 | dataset.write(document_path, oid) 61 | end 62 | 63 | database.current do |dataset| 64 | expect(dataset[document_path].data).to be == sample_json 65 | end 66 | end 67 | 68 | it "should erase a document" do 69 | database.commit(message: "Create test document") do |dataset| 70 | oid = dataset.append(sample_json) 71 | dataset.write(document_path, oid) 72 | end 73 | 74 | database.commit(message: "Delete test document") do |dataset| 75 | dataset.delete(document_path) 76 | end 77 | 78 | database.current do |dataset| 79 | expect(dataset[document_path]).to be_nil 80 | end 81 | end 82 | 83 | it "should create multiple documents" do 84 | database.commit(message: "Create first document") do |dataset| 85 | oid = dataset.append(sample_json) 86 | dataset.write(document_path, oid) 87 | end 88 | 89 | database.commit(message: "Create second document") do |dataset| 90 | oid = dataset.append(sample_json) 91 | dataset.write(document_path + "2", oid) 92 | end 93 | 94 | database.current do |dataset| 95 | expect(dataset[document_path].data).to be == sample_json 96 | expect(dataset[document_path + "2"].data).to be == sample_json 97 | end 98 | end 99 | 100 | it "can enumerate documents" do 101 | database.commit(message: "Create first document") do |dataset| 102 | oid = dataset.append(sample_json) 103 | 104 | 10.times do |id| 105 | dataset.write(document_path + "-#{id}", oid) 106 | end 107 | end 108 | 109 | database.current do |dataset| 110 | expect(dataset.each("test").count).to be == 10 111 | end 112 | end 113 | 114 | it "can enumerate commit history of a document" do 115 | 10.times do |id| 116 | database.commit(message: "revising the document #{id}") do |changeset| 117 | oid = changeset.append("revision \##{id} of this document") 118 | changeset.write("test/doot.txt", oid) 119 | end 120 | end 121 | 122 | database.commit(message: "unrelated commit") do |changeset| 123 | oid = changeset.append("unrelated document") 124 | changeset.write("test/unrelated.txt", oid) 125 | end 126 | 127 | expect(database.history("test/doot.txt").count).to be == 10 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/relaxo/database.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2012-2025, by Samuel Williams. 5 | # Copyright, 2017, by Huba Nagy. 6 | 7 | require "rugged" 8 | 9 | require_relative "logger" 10 | require_relative "dataset" 11 | require_relative "changeset" 12 | 13 | module Relaxo 14 | HEAD = "HEAD".freeze 15 | 16 | class Database 17 | def initialize(path, branch, metadata = {}) 18 | @path = path 19 | @metadata = metadata 20 | 21 | @repository = Rugged::Repository.new(path) 22 | # @repository.config['core.fsyncObjectFiles'] = fsync 23 | 24 | @branch = branch 25 | end 26 | 27 | def config 28 | @repository.config 29 | end 30 | 31 | attr :path 32 | attr :metadata 33 | attr :repository 34 | 35 | # @attribute branch [String] The branch that this database is currently working with. 36 | attr :branch 37 | 38 | # Completely clear out the database. 39 | def clear! 40 | if head = @repository.branches[@branch] 41 | @repository.references.delete(head) 42 | end 43 | end 44 | 45 | def empty? 46 | @repository.empty? 47 | end 48 | 49 | def head 50 | @repository.branches[@branch] 51 | end 52 | 53 | def [] key 54 | @metadata[key] 55 | end 56 | 57 | # During the execution of the block, changes don't get stored immediately, so reading from the dataset (from outside the block) will continue to return the values that were stored in the configuration when the transaction was started. 58 | # @return the result of the block. 59 | def commit(**options) 60 | result = nil 61 | 62 | track_time(options[:message]) do 63 | catch(:abort) do 64 | begin 65 | parent, tree = latest_commit 66 | 67 | changeset = Changeset.new(@repository, tree) 68 | 69 | result = yield changeset 70 | end until apply(parent, changeset, **options) 71 | end 72 | end 73 | 74 | return result 75 | end 76 | 77 | # Efficient point-in-time read-only access. 78 | def current 79 | _, tree = latest_commit 80 | 81 | dataset = Dataset.new(@repository, tree) 82 | 83 | yield dataset if block_given? 84 | 85 | return dataset 86 | end 87 | 88 | # revision history of given object 89 | def history(path) 90 | head, _ = latest_commit 91 | 92 | walker = Rugged::Walker.new(@repository) # Sounds like 'Walker, Texas Ranger'... 93 | walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_REVERSE) 94 | walker.push(head.oid) 95 | 96 | commits = [] 97 | 98 | old_oid = nil 99 | 100 | walker.each do |commit| 101 | dataset = Dataset.new(@repository, commit.tree) 102 | oid = dataset.read(path).oid 103 | 104 | if oid != old_oid # modified 105 | yield commit if block_given? 106 | commits << commit 107 | old_oid = oid 108 | end 109 | 110 | break if oid.nil? && !old_oid.nil? # deleted or moved 111 | end 112 | 113 | return commits 114 | end 115 | 116 | private 117 | 118 | def track_time(message) 119 | start_time = Time.now 120 | 121 | yield 122 | ensure 123 | end_time = Time.now 124 | elapsed_time = end_time - start_time 125 | 126 | Console.debug(self) {"#{message.inspect}: %0.3fs" % elapsed_time} 127 | end 128 | 129 | def apply(parent, changeset, **options) 130 | return true unless changeset.changes? 131 | 132 | options[:tree] = changeset.write_tree 133 | options[:parents] ||= [parent] 134 | options[:update_ref] ||= "refs/heads/#{@branch}" 135 | 136 | begin 137 | Rugged::Commit.create(@repository, options) 138 | rescue Rugged::ObjectError 139 | return false 140 | end 141 | end 142 | 143 | def latest_commit 144 | if head = self.head 145 | return head.target, head.target.tree 146 | else 147 | return nil, empty_tree 148 | end 149 | end 150 | 151 | def empty_tree 152 | @empty_tree ||= Rugged::Tree.empty(@repository) 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ![Relaxo](logo.svg) 2 | 3 | Relaxo is a transactional database built on top of git. It's aim is to provide a robust interface for document storage and sorted indexes. If you prefer a higher level interface, you can try [relaxo-model](https://github.com/ioquatix/relaxo-model). 4 | 5 | [![Development Status](https://github.com/ioquatix/relaxo/workflows/Test/badge.svg)](https://github.com/ioquatix/relaxo/actions?workflow=Test) 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ``` ruby 12 | gem 'relaxo' 13 | ``` 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install relaxo 22 | 23 | ## Usage 24 | 25 | Connect to a local database and manipulate some documents. 26 | 27 | ``` ruby 28 | require 'relaxo' 29 | require 'msgpack' 30 | 31 | DB = Relaxo.connect("test") 32 | 33 | DB.commit(message: "Create test data") do |dataset| 34 | object = dataset.append(MessagePack.dump({bob: 'dole'})) 35 | dataset.write("doc1.msgpack", object) 36 | end 37 | 38 | DB.commit(message: "Update test data") do |dataset| 39 | doc = MessagePack.load dataset.read('doc1.msgpack').data 40 | doc[:foo] = 'bar' 41 | 42 | object = dataset.append(MessagePack.dump(doc)) 43 | dataset.write("doc2.msgpack", object) 44 | end 45 | 46 | doc = MessagePack.load DB.current['doc2.msgpack'].data 47 | puts doc 48 | # => {"bob"=>"dole", "foo"=>"bar"} 49 | ``` 50 | 51 | ### Document Storage 52 | 53 | Relaxo uses the git persistent data structure for storing documents. This data structure exposes a file-system like interface, which stores any kind of data. This means that you are free to use JSON, or BSON, or MessagePack, or JPEG, or XML, or any combination of those. 54 | 55 | Relaxo has a transactional model for both reading and writing. 56 | 57 | #### Authors 58 | 59 | By default, Relaxo sets up the repository author using the login name and hostname of the current session. You can explicitly change this by modifying `database.config`. Additionally, you can set this per-commit: 60 | 61 | ``` ruby 62 | database.commit(message: "Testing Enumeration", author: {user: "Alice", email: "alice@localhost"}) do |dataset| 63 | object = dataset.append("Hello World!") 64 | dataset.write("hello.txt", object) 65 | end 66 | ``` 67 | 68 | #### Reading Files 69 | 70 | ``` ruby 71 | path = "path/to/document" 72 | 73 | DB.current do |dataset| 74 | object = dataset.read(path) 75 | 76 | puts "The object id: #{object.oid}" 77 | puts "The object data size: #{object.size}" 78 | puts "The object data: #{object.data.inspect}" 79 | end 80 | ``` 81 | 82 | #### Writing Files 83 | 84 | ``` ruby 85 | path = "path/to/document" 86 | data = MessagePack.dump(document) 87 | 88 | DB.commit(message: "Adding document") do |changeset| 89 | object = changeset.append(data) 90 | changeset.write(path, object) 91 | end 92 | ``` 93 | 94 | ### Datasets and Transactions 95 | 96 | `Dataset`s and `Changeset`s are important concepts. Relaxo doesn't allow arbitrary access to data, but instead exposes the git persistent model for both reading and writing. The implications of this are that when reading or writing, you always see a consistent snapshot of the data store. 97 | 98 | ### Suitability 99 | 100 | Relaxo is designed to scale to the hundreds of thousands of documents. It's designed around the git persistent data store, and therefore has some performance and concurrency limitations due to the underlying implementation. 101 | 102 | Because it maintains a full history of all changes, the repository would continue to grow over time by default, but there are mechanisms to deal with that. 103 | 104 | #### Performance 105 | 106 | Relaxo can do anywhere from 1000-10,000 inserts per second depending on how you structure the workload. 107 | 108 | Relaxo Performance 109 | Warming up -------------------------------------- 110 | single 129.000 i/100ms 111 | Calculating ------------------------------------- 112 | single 6.224k (±14.7%) i/s - 114.036k in 20.000025s 113 | single transaction should be fast 114 | Warming up -------------------------------------- 115 | multiple 152.000 i/100ms 116 | Calculating ------------------------------------- 117 | multiple 1.452k (±15.2%) i/s - 28.120k in 20.101831s 118 | multiple transactions should be fast 119 | 120 | Reading data is lighting fast as it's loaded directly from disk and cached. 121 | 122 | ### Loading Data 123 | 124 | As Relaxo is unapologetically based on git, you can use git directly with a non-bare working directory to add any files you like. You can even point Relaxo at an existing git repository. 125 | 126 | ### Durability 127 | 128 | Relaxo is based on `libgit2` and asserts that it is a transactional database. We base this assertion on: 129 | 130 | - All writes into the object store using `libgit2` are atomic and synchronized to disk. 131 | - All updates to refs are atomic and synchronized to disk. 132 | 133 | Provided these two invariants are maintained, the operation of Relaxo will be safe, even if there are unexpected interruptions to the program. 134 | 135 | The durability guarantees of Relaxo depend on [`libgit2` calling `fsync`](https://github.com/libgit2/libgit2/pull/4030), and [this being respected by the underlying hardware](http://www.evanjones.ca/intel-ssd-durability.html). Otherwise, durability cannot be guaranteed. 136 | 137 | ## Contributing 138 | 139 | We welcome contributions to this project. 140 | 141 | 1. Fork it. 142 | 2. Create your feature branch (`git checkout -b my-new-feature`). 143 | 3. Commit your changes (`git commit -am 'Add some feature'`). 144 | 4. Push to the branch (`git push origin my-new-feature`). 145 | 5. Create new Pull Request. 146 | 147 | ### Developer Certificate of Origin 148 | 149 | In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. 150 | 151 | ### Community Guidelines 152 | 153 | This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. 154 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | --------------------------------------------------------------------------------