rom-tiger_beetle 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7372865e0566fe634b9f8e13ee576ea92d29f186f43383fe43a1b43dec1021c6
4
+ data.tar.gz: 378f7c274cbf5f21d9e2d25dff24179135ac4c4dea47bc745ab74f2a62fc12a2
5
+ SHA512:
6
+ metadata.gz: f8749d68832fbb1db874d43e57b90e89df304ef70d56bd7da1093ef1b85e69017f500aa4e4a98cb3c6bdb1e6bbeb6e476daa648672d9fc41685822869a88c6d4
7
+ data.tar.gz: 2438aedb8ee7acb28f0d9f455237a073c1b740c654ae437eb9ebdee7c323b01b97348c9ab60a0394cb04a8f06c34c4c1d33ba178d75efd276f8e71fd7d5b41bf
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-10
4
+
5
+ First supported release. Supports MRI Ruby 3.3 and 3.4 with the TigerBeetle
6
+ Ruby client 0.17.9 and the TigerBeetle 0.17.9 server image.
7
+
8
+ - Verified with the clean-bundle CI matrix, the complete RSpec suite, and the
9
+ example against the pinned local server image.
10
+
11
+ ## Unreleased
12
+
13
+ - Document the current `TigerBeetle::Client.new` setup and per-record `CREATED`/`EXISTS` result handling.
14
+ - Document the supported Ruby 3.3+, TigerBeetle client 0.17.9, and server 0.17.9 compatibility range, including batch limits, ID idempotency, history filters, query scope, and pending transfers.
15
+ - Correct the gem homepage and repository metadata to point to the public `kamalogudah/rom-tiger_beetle` repository.
16
+
17
+ - Clarify the ID-only query boundary and supported history escape hatches.
18
+ - Validate account/transfer protocol ranges, flags, account identity, and transfer phase invariants before submission.
19
+
20
+ - Require Ruby 3.3 or newer to match the TigerBeetle 0.17 client.
21
+ - Constrain the TigerBeetle client to the tested `>= 0.17.9, < 0.18` range.
22
+ - Declare `rom` as a development dependency for the specs.
23
+
24
+ - Initial adapter: `:accounts`/`:transfers` datasets, id-batch lookup, batch create commands, flag bitmask helpers, monotonic id generation.
25
+ - Known gaps: two-phase transfer helpers, linked-chain builder, unconfirmed `get_account_transfers` signature.
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) Paul Oguda
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # rom-tiger_beetle
2
+
3
+ Experimental [TigerBeetle](https://tigerbeetle.com) support for [ROM](https://rom-rb.org). Uses the [`tigerbeetle`](https://rubygems.org/gems/tigerbeetle) gem (native Zig client via FFI) as the connection adapter.
4
+
5
+ TigerBeetle is not a general-purpose database — it's an append-only, distributed accounting ledger with exactly two record types (`Account`, `Transfer`) and no arbitrary predicate queries. This adapter reflects that on purpose rather than papering over it with a fake SQL-like interface:
6
+
7
+ - **Two datasets only** — `:accounts` and `:transfers`, fixed by the protocol.
8
+ - **`where(id: ...)` only** — batch lookup by id, via `lookup_accounts`/`lookup_transfers`. Any other predicate raises `NotImplementedError`.
9
+ - **Create only, batched** — `create_accounts`/`create_transfers`. There is no `Update` or `Delete` command: ledger entries are immutable, and balances only change as a side effect of new transfers.
10
+ - **`TigerBeetle::CommandError`** wraps TigerBeetle's per-item error array (the protocol returns errors inline rather than raising).
11
+
12
+ Record IDs are cluster-wide idempotency keys, not ledger-scoped identifiers. Keep
13
+ cross-ledger relationships in `user_data_128`, `user_data_64`, or `user_data_32`;
14
+ the account and transfer IDs themselves must be nonzero and less than `2^128 - 1`.
15
+ For normal creates, submit `timestamp: 0` (the default) and let TigerBeetle
16
+ assign it.
17
+
18
+ Create requests are chunked in input order. Records with the `linked` flag are
19
+ kept together with the rest of their linked chain and are never split across
20
+ requests. A linked chain larger than `batch_size` is rejected before any create
21
+ request is sent.
22
+
23
+ ## Installing
24
+
25
+ Add `gem "rom-tiger_beetle"` to your Gemfile. You'll need a running TigerBeetle cluster — see `docker-compose.yml` in this repo for a local single-replica setup.
26
+
27
+ ## Example
28
+
29
+ ```ruby
30
+ require "rom"
31
+ require "rom/tiger_beetle"
32
+
33
+ conf = ROM::Configuration.new(
34
+ :tiger_beetle,
35
+ addresses: "3000",
36
+ client: TigerBeetle::Client.new(
37
+ cluster_id: 0,
38
+ replica_addresses: "3000"
39
+ ),
40
+ # 8,189 is the documented default, not a guaranteed limit for every server.
41
+ # Set this to the maximum advertised by your cluster.
42
+ batch_size: 8_189
43
+ )
44
+
45
+ class Accounts < ROM::Relation[:tiger_beetle]
46
+ schema(:accounts, infer: false) do
47
+ attribute :id, ROM::TigerBeetle::Types::UInt128ID
48
+ attribute :user_data_128, ROM::TigerBeetle::Types::UInt128.default(0)
49
+ attribute :user_data_64, ROM::TigerBeetle::Types::UInt64.default(0)
50
+ attribute :user_data_32, ROM::TigerBeetle::Types::UInt32.default(0)
51
+ attribute :ledger, ROM::TigerBeetle::Types::UInt32
52
+ attribute :code, ROM::TigerBeetle::Types::UInt16
53
+ attribute :flags, ROM::TigerBeetle::Types::UInt16.default(0)
54
+ # Leave this at zero on create; TigerBeetle assigns the timestamp.
55
+ attribute :timestamp, ROM::TigerBeetle::Types::UInt64.default(0)
56
+ end
57
+ end
58
+
59
+ class Transfers < ROM::Relation[:tiger_beetle]
60
+ schema(:transfers, infer: false) do
61
+ attribute :id, ROM::TigerBeetle::Types::UInt128ID
62
+ attribute :user_data_128, ROM::TigerBeetle::Types::UInt128.default(0)
63
+ attribute :user_data_64, ROM::TigerBeetle::Types::UInt64.default(0)
64
+ attribute :user_data_32, ROM::TigerBeetle::Types::UInt32.default(0)
65
+ attribute :debit_account_id, ROM::TigerBeetle::Types::UInt128ID
66
+ attribute :credit_account_id, ROM::TigerBeetle::Types::UInt128ID
67
+ attribute :amount, ROM::TigerBeetle::Types::UInt128
68
+ attribute :ledger, ROM::TigerBeetle::Types::UInt32
69
+ attribute :code, ROM::TigerBeetle::Types::UInt16
70
+ attribute :flags, ROM::TigerBeetle::Types::UInt16.default(0)
71
+ attribute :pending_id, ROM::TigerBeetle::Types::UInt128.default(0)
72
+ attribute :timeout, ROM::TigerBeetle::Types::UInt64.default(0)
73
+ # Leave this at zero on create; TigerBeetle assigns the timestamp.
74
+ attribute :timestamp, ROM::TigerBeetle::Types::UInt64.default(0)
75
+ end
76
+ end
77
+
78
+ conf.register_relation(Accounts, Transfers)
79
+ conf.register_command(
80
+ Class.new(ROM::TigerBeetle::Commands::CreateAccounts) { relation :accounts; register_as :create },
81
+ Class.new(ROM::TigerBeetle::Commands::CreateTransfers) { relation :transfers; register_as :create }
82
+ )
83
+
84
+ rom = ROM.container(conf)
85
+
86
+ client_wallet = ROM::TigerBeetle::Types.generate_id
87
+ expert_wallet = ROM::TigerBeetle::Types.generate_id
88
+
89
+ rom.commands[:accounts][:create].call([
90
+ { id: client_wallet, ledger: 1, code: 1 },
91
+ { id: expert_wallet, ledger: 1, code: 2 }
92
+ ])
93
+
94
+ rom.commands[:transfers][:create].call([{
95
+ id: ROM::TigerBeetle::Types.generate_id,
96
+ debit_account_id: client_wallet,
97
+ credit_account_id: expert_wallet,
98
+ amount: 25_000,
99
+ ledger: 1,
100
+ code: 10
101
+ }])
102
+
103
+ rom.relations[:accounts].by_id(client_wallet, expert_wallet).to_a
104
+ ```
105
+
106
+ The adapter passes arrays to the current TigerBeetle Ruby client API. The
107
+ underlying client returns one result object per submitted record; `CREATED` and
108
+ `EXISTS` are successful outcomes. The ROM create command returns the input
109
+ tuples on success and raises `ROM::TigerBeetle::CommandError` when any result
110
+ has another status.
111
+
112
+ If using the client directly, handle the result objects in the same way:
113
+
114
+ ```ruby
115
+ results = client.create_accounts(accounts)
116
+ successful = [TigerBeetle::CreateAccountStatus::CREATED,
117
+ TigerBeetle::CreateAccountStatus::EXISTS]
118
+ failures = results.reject { |result| successful.include?(result.status) }
119
+ raise failures.inspect unless failures.empty?
120
+ ```
121
+
122
+ ## IDs and batches
123
+
124
+ Account and transfer IDs are cluster-wide idempotency keys. They are not scoped
125
+ to a ledger, so retries with the same ID return `EXISTS` rather than creating a
126
+ second record. IDs must be nonzero and less than `2^128 - 1`; use
127
+ `ROM::TigerBeetle::Types.generate_id` for new records. Store application
128
+ relationships in `user_data_128`, `user_data_64`, or `user_data_32` instead of
129
+ reusing an ID across ledgers.
130
+
131
+ Create requests are chunked in input order. The default maximum is 8,189
132
+ records, but the server configured event limit is authoritative; set the
133
+ gateway batch_size to the limit advertised by your cluster. A linked chain
134
+ is kept in one request and a chain larger than `batch_size` is rejected.
135
+
136
+ ## Query and accounting boundaries
137
+
138
+ This adapter is intentionally ID-only: `where(id: ...)` and `by_id` perform
139
+ batch lookups, and `history_for` and `balances_for` are the only account-scoped
140
+ protocol queries. It does not expose `query_accounts` or `query_transfers`,
141
+ scan datasets, or emulate SQL predicates. Filters such as ledger, code, and
142
+ account flags must be applied by the caller after an explicit ID lookup or by
143
+ using the supported account-history filter.
144
+
145
+ History and point-in-time balances are available only for accounts created with
146
+ `ROM::TigerBeetle::Flags.account(:history)`. For example:
147
+
148
+ ```ruby
149
+ history = rom.relations[:accounts].history_for(
150
+ account_id,
151
+ limit: 100,
152
+ debits: true,
153
+ credits: true,
154
+ reversed: true,
155
+ timestamp_min: 0,
156
+ timestamp_max: 0
157
+ )
158
+ balances = rom.relations[:accounts].balances_for(account_id, limit: 100)
159
+ ```
160
+
161
+ The history filter supports `user_data_128`, `user_data_64`, `user_data_32`,
162
+ `code`, `timestamp_min`, `timestamp_max`, `limit`, `reversed`, and explicit
163
+ `flags`. At least one of `debits` or `credits` must be selected, and `limit`
164
+ must be positive.
165
+
166
+ Creates validate tuple-level protocol boundaries before sending: IDs are nonzero and below 2^128 - 1, ledger and code are nonzero (uint32/uint16), transfer accounts are distinct, amounts are unsigned uint128, and account/transfer flags contain only known bits. TigerBeetle remains authoritative for the relationship between a transfer and its accounts: both accounts must exist and have the same ledger as the transfer. The adapter does not issue hidden account queries to emulate that predicate.
167
+
168
+ Account debits_posted, debits_pending, credits_posted, and credits_pending are cumulative totals, not signed balances. A posted debit increases debits_posted; a posted credit increases credits_posted; pending transfers affect only pending totals; voided transfers and timed-out pending transfers do not affect posted totals. Posting a pending transfer moves its amount into posted totals, while voiding it removes the pending effect.
169
+
170
+ ## Known gaps
171
+
172
+ - **Pending transfers** — use `create_pending_transfers`, `post_pending_transfers`, and `void_pending_transfers` on the transfers dataset. A pending transfer has the `pending` flag, a positive `timeout`, and `pending_id: 0`. Posting or voiding it uses the corresponding phase flag and a nonzero `pending_id` referring to the original transfer. Posting moves the amount into posted totals; voiding removes the pending effect without posting it.
173
+ - **No linked-chain (atomic batch) convenience API.** You can set `flags.linked` yourself via `ROM::TigerBeetle::Flags.transfer(:linked)`, but there's no builder that manages the chain for you.
174
+ - **Account history uses complete `AccountFilter` values.** `history_for` reads transfers and `balances_for` reads point-in-time balances. Both require accounts created with `flags.history` set, request debit and credit records with a positive limit, and support `timestamp_min`, `timestamp_max`, `reversed`, user-data, code, and explicit `flags` options for pagination and ordering.
175
+ - **Flag bit positions are asserted from the documented protocol**, not read from the gem's own constants (if it exposes `TigerBeetle::AccountFlags`/`TransferFlags`, prefer those over `ROM::TigerBeetle::Flags`).
176
+
177
+ ## Verification
178
+
179
+ From a clean checkout, install into Bundler's isolated bundle, start the pinned
180
+ TigerBeetle server, run the complete suite, and execute the example:
181
+
182
+ ```
183
+ bundle install
184
+ docker compose up -d
185
+ bundle exec rspec
186
+ bundle exec ruby example.rb
187
+ docker compose down -v
188
+ ```
189
+
190
+ The example verifies account creation, transfer creation, and the resulting
191
+ posted balances. The GitHub Actions workflow runs this same path for every
192
+ supported matrix entry and uses a fresh temporary bundle, so globally installed
193
+ gems are not used.
194
+
195
+ ## Supported Ruby versions
196
+
197
+ - MRI 3.3 and 3.4 (the CI-supported matrix)
198
+
199
+ ## Supported dependencies
200
+
201
+ This adapter is tested with `rom`/`rom-core` 5.4 and TigerBeetle Ruby client
202
+ `0.17.9`. The local spec setup and Docker Compose server use the same
203
+ TigerBeetle `0.17.9` release; the gemspec pins that client version so the
204
+ Ruby/server protocol pair cannot drift.
205
+
206
+ ## License
207
+
208
+ See `LICENSE.txt`.
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rom/commands"
4
+
5
+ module ROM
6
+ module TigerBeetle
7
+ module Commands
8
+ # There is deliberately no Update or Delete command in this adapter.
9
+ # TigerBeetle accounts/transfers are append-only ledger entries —
10
+ # balances change only as a side effect of new transfers, and nothing
11
+ # is ever mutated or removed in place. Modeling "update" here would
12
+ # misrepresent what the database actually does.
13
+ class CreateAccounts < ROM::Commands::Create
14
+ adapter :tiger_beetle
15
+
16
+ def execute(tuples)
17
+ relation.dataset.insert(tuples)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rom/commands"
4
+
5
+ module ROM
6
+ module TigerBeetle
7
+ module Commands
8
+ class CreatePendingTransfers < ROM::Commands::Create
9
+ adapter :tiger_beetle
10
+
11
+ def execute(tuples)
12
+ relation.dataset.create_pending_transfers(tuples)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rom/commands"
4
+
5
+ module ROM
6
+ module TigerBeetle
7
+ module Commands
8
+ class CreateTransfers < ROM::Commands::Create
9
+ adapter :tiger_beetle
10
+
11
+ def execute(tuples)
12
+ relation.dataset.insert(tuples)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rom/commands"
4
+
5
+ module ROM
6
+ module TigerBeetle
7
+ module Commands
8
+ class PostPendingTransfers < ROM::Commands::Create
9
+ adapter :tiger_beetle
10
+
11
+ def execute(tuples)
12
+ relation.dataset.post_pending_transfers(tuples)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rom/commands"
4
+
5
+ module ROM
6
+ module TigerBeetle
7
+ module Commands
8
+ class VoidPendingTransfers < ROM::Commands::Create
9
+ adapter :tiger_beetle
10
+
11
+ def execute(tuples)
12
+ relation.dataset.void_pending_transfers(tuples)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rom/tiger_beetle/commands/create_accounts"
4
+ require "rom/tiger_beetle/commands/create_transfers"
5
+ require "rom/tiger_beetle/commands/create_pending_transfers"
6
+ require "rom/tiger_beetle/commands/post_pending_transfers"
7
+ require "rom/tiger_beetle/commands/void_pending_transfers"
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ROM
4
+ module TigerBeetle
5
+ # TigerBeetle intentionally does not support arbitrary predicate queries
6
+ # or full-table scans (that's the performance trade-off that makes it
7
+ # fast). What it supports:
8
+ # - batch lookup by id -> lookup_accounts / lookup_transfers
9
+ # - batch create (append-only) -> create_accounts / create_transfers / two-phase transfer operations
10
+ # - id-scoped history queries -> get_account_transfers / get_account_balances
11
+ # (only for accounts created with flags.history set)
12
+ #
13
+ # This Dataset reflects that honestly instead of faking a SQL-like
14
+ # interface: `where` only accepts `:id`, and there is no `update`/general
15
+ # `delete` because ledger entries are immutable once created.
16
+ class Dataset
17
+ include Enumerable
18
+
19
+ # 8,189 is the documented default maximum, not a universal server limit.
20
+ # Configure batch_size to the limit advertised by the cluster.
21
+ DEFAULT_BATCH_SIZE = 8_189
22
+ LINKED_FLAG = 1 << 0
23
+
24
+ attr_reader :client, :kind, :ids, :batch_size
25
+
26
+ def initialize(client:, kind:, ids: nil, batch_size: DEFAULT_BATCH_SIZE)
27
+ @client = client
28
+ @kind = kind
29
+ @ids = ids
30
+ @batch_size = Integer(batch_size)
31
+ raise ArgumentError, "batch_size must be greater than zero" unless @batch_size.positive?
32
+ end
33
+
34
+ def where(conditions)
35
+ unless conditions.keys == [:id]
36
+ raise NotImplementedError,
37
+ "TigerBeetle only supports lookup by :id, not arbitrary predicates (#{conditions.keys.inspect}). " \
38
+ "Use #related history methods (get_account_transfers/get_account_balances) for account-scoped queries instead."
39
+ end
40
+
41
+ new_ids = Array(conditions[:id])
42
+ self.class.new(client: client, kind: kind, ids: new_ids, batch_size: batch_size)
43
+ end
44
+ alias_method :restrict, :where
45
+
46
+ def each(&block)
47
+ return to_enum(:each) unless block_given?
48
+
49
+ rows.each(&block)
50
+ end
51
+
52
+ def insert(tuples)
53
+ batch = Array(tuples).flatten
54
+ create_method = kind == :accounts ? :create_accounts : :create_transfers
55
+
56
+ kind == :accounts ? validate_accounts!(batch) : validate_transfers!(batch, :single)
57
+
58
+ results = chunks(batch).flat_map do |chunk|
59
+ Array(client.public_send(create_method, chunk.map { |t| build(t) }))
60
+ end
61
+ errors = results.reject { |result| create_succeeded?(result) }
62
+
63
+ unless errors.empty?
64
+ raise ROM::TigerBeetle::CommandError.new(kind, errors)
65
+ end
66
+
67
+ batch
68
+ end
69
+
70
+ def insert_without_validation(tuples)
71
+ batch = Array(tuples).flatten
72
+ create_method = kind == :accounts ? :create_accounts : :create_transfers
73
+ results = chunks(batch).flat_map { |chunk| Array(client.public_send(create_method, chunk.map { |t| build(t) })) }
74
+ errors = results.reject { |result| create_succeeded?(result) }
75
+ raise ROM::TigerBeetle::CommandError.new(kind, errors) unless errors.empty?
76
+ batch
77
+ end
78
+
79
+ def create_pending_transfers(tuples)
80
+ transfer_operation(tuples, :pending)
81
+ end
82
+
83
+ def post_pending_transfers(tuples)
84
+ transfer_operation(tuples, :post_pending)
85
+ end
86
+
87
+ def void_pending_transfers(tuples)
88
+ transfer_operation(tuples, :void_pending)
89
+ end
90
+
91
+ alias pending_transfers create_pending_transfers
92
+ alias post_pending post_pending_transfers
93
+ alias void_pending void_pending_transfers
94
+
95
+ private :insert_without_validation
96
+
97
+ # Account-scoped transfer history. Accounts must have been created with
98
+ # flags.history set. `timestamp_min` and `timestamp_max` can be used as
99
+ # cursors for pagination; `reversed` returns newest records first.
100
+ def history_for(account_id, **options)
101
+ account_history(account_id, :get_account_transfers, **options)
102
+ end
103
+
104
+ # Point-in-time balances. Accounts must have been created with
105
+ # flags.history set, otherwise TigerBeetle cannot retain this history.
106
+ # The filter options are the same as #history_for.
107
+ def balances_for(account_id, **options)
108
+ account_history(account_id, :get_account_balances, **options)
109
+ end
110
+ alias account_balances_for balances_for
111
+
112
+ TRANSFER_PHASE_FLAGS = { pending: Flags.transfer(:pending), post_pending: Flags.transfer(:post_pending_transfer), void_pending: Flags.transfer(:void_pending_transfer) }.freeze
113
+ TRANSFER_PHASE_MASK = TRANSFER_PHASE_FLAGS.values.sum
114
+ TRANSFER_KNOWN_FLAGS = Flags::TRANSFER.values.sum
115
+ ACCOUNT_KNOWN_FLAGS = Flags::ACCOUNT.values.sum
116
+ UINT16_MAX = (2**16) - 1
117
+ UINT32_MAX = (2**32) - 1
118
+ UINT128_MAX = (2**128) - 1
119
+ UINT64_MAX = (2**64) - 1
120
+
121
+ private
122
+
123
+ def account_history(account_id, method_name, limit: 100, user_data_128: 0, user_data_64: 0, user_data_32: 0, code: 0, timestamp_min: 0, timestamp_max: 0, flags: nil, debits: true, credits: true, reversed: false)
124
+ return [] unless kind == :accounts
125
+ unless client.respond_to?(method_name)
126
+ raise NotImplementedError, "installed tigerbeetle gem does not expose this history operation"
127
+ end
128
+
129
+ limit = Integer(limit)
130
+ raise ArgumentError, "limit must be greater than zero" unless limit.positive?
131
+ timestamp_min = Integer(timestamp_min)
132
+ timestamp_max = Integer(timestamp_max)
133
+ raise ArgumentError, "timestamps must be non-negative" if timestamp_min.negative? || timestamp_max.negative?
134
+ raise ArgumentError, "timestamp_max must be greater than or equal to timestamp_min" if timestamp_max.positive? && timestamp_max < timestamp_min
135
+
136
+ filter_flags = flags.nil? ? 0 : Integer(flags)
137
+ if flags.nil?
138
+ filter_flags |= ::TigerBeetle::AccountFilterFlags::DEBITS if debits
139
+ filter_flags |= ::TigerBeetle::AccountFilterFlags::CREDITS if credits
140
+ end
141
+ filter_flags |= ::TigerBeetle::AccountFilterFlags::REVERSED if reversed
142
+ raise ArgumentError, "at least one of debits or credits must be selected" if (filter_flags & 3).zero?
143
+
144
+ # Pass every AccountFilter field explicitly. This keeps the adapter
145
+ # correct if client defaults change and makes pagination/order visible.
146
+ filter = ::TigerBeetle::AccountFilter.new(account_id: account_id, user_data_128: Integer(user_data_128), user_data_64: Integer(user_data_64), user_data_32: Integer(user_data_32), code: Integer(code), timestamp_min: timestamp_min, timestamp_max: timestamp_max, limit: limit, flags: filter_flags)
147
+
148
+ client.public_send(method_name, filter)
149
+ end
150
+
151
+ def transfer_operation(tuples, mode)
152
+ raise NotImplementedError, "transfer operations are only available on the transfers dataset" unless kind == :transfers
153
+ batch = Array(tuples).flatten
154
+ validate_transfers!(batch, mode)
155
+ insert_without_validation(batch)
156
+ end
157
+
158
+ def validate_transfers!(batch, mode)
159
+ batch.each_with_index do |tuple, index|
160
+ raise ArgumentError, "transfer at index #{index} must be a hash-like object" unless tuple.respond_to?(:[])
161
+ validate_transfer_fields!(tuple, index)
162
+ flags = integer_field(tuple, :flags, 0, index)
163
+ pending_id = integer_field(tuple, :pending_id, 0, index)
164
+ timeout = integer_field(tuple, :timeout, 0, index)
165
+ raise ArgumentError, "transfer at index #{index} has invalid flags #{flags.inspect}" if flags.negative? || (flags & ~TRANSFER_KNOWN_FLAGS).positive?
166
+ raise ArgumentError, "transfer at index #{index} has invalid pending_id #{pending_id.inspect}" if pending_id.negative? || pending_id >= UINT128_MAX
167
+ raise ArgumentError, "transfer at index #{index} has invalid timeout #{timeout.inspect}" if timeout.negative? || timeout > UINT64_MAX
168
+ phase_flag = flags & TRANSFER_PHASE_MASK
169
+ expected_flag = mode == :single ? 0 : TRANSFER_PHASE_FLAGS.fetch(mode)
170
+ raise ArgumentError, "transfer at index #{index} must be single-phase (no phase flag)" if mode == :single && phase_flag != 0
171
+ raise ArgumentError, "transfer at index #{index} must use the #{mode} transfer flag" unless mode == :single || phase_flag == expected_flag
172
+ case mode
173
+ when :single
174
+ raise ArgumentError, "pending_id must be zero for a single-phase transfer" unless pending_id.zero?
175
+ raise ArgumentError, "amount must be greater than zero for a single-phase transfer" unless tuple[:amount].positive?
176
+ raise ArgumentError, "timeout must be zero for a single-phase transfer" unless timeout.zero?
177
+ when :pending
178
+ require_transfer_fields!(tuple, index)
179
+ raise ArgumentError, "amount must be greater than zero for a pending transfer" unless tuple[:amount].positive?
180
+ raise ArgumentError, "pending_id must be zero for a pending transfer" unless pending_id.zero?
181
+ raise ArgumentError, "timeout must be greater than zero for a pending transfer" unless timeout.positive?
182
+ when :post_pending, :void_pending
183
+ require_transfer_fields!(tuple, index)
184
+ if mode == :void_pending
185
+ raise ArgumentError, "amount must be zero for a void_pending transfer" unless tuple[:amount].zero?
186
+ else
187
+ raise ArgumentError, "amount must be greater than zero for a post_pending transfer" unless tuple[:amount].positive?
188
+ end
189
+ raise ArgumentError, "pending_id must be nonzero and less than 2^128 - 1 for a #{mode} transfer" unless pending_id.positive? && pending_id < UINT128_MAX
190
+ raise ArgumentError, "timeout must be zero for a #{mode} transfer" unless timeout.zero?
191
+ end
192
+ end
193
+ end
194
+
195
+ def validate_accounts!(batch)
196
+ batch.each_with_index do |tuple, index|
197
+ raise ArgumentError, "account at index #{index} must be a hash-like object" unless tuple.respond_to?(:[])
198
+ validate_uint128_id!(tuple[:id], :id, index, "account")
199
+ ledger = required_integer!(tuple, :ledger, index, "account")
200
+ code = required_integer!(tuple, :code, index, "account")
201
+ flags = integer_field(tuple, :flags, 0, index)
202
+ validate_range!(ledger, 1, UINT32_MAX, :ledger, index, "account")
203
+ validate_range!(code, 1, UINT16_MAX, :code, index, "account")
204
+ validate_range!(flags, 0, ACCOUNT_KNOWN_FLAGS, :flags, index, "account")
205
+ end
206
+ end
207
+
208
+ def validate_transfer_fields!(tuple, index)
209
+ validate_uint128_id!(tuple[:id], :id, index, "transfer")
210
+ debit = tuple[:debit_account_id]
211
+ credit = tuple[:credit_account_id]
212
+ validate_uint128_id!(debit, :debit_account_id, index, "transfer")
213
+ validate_uint128_id!(credit, :credit_account_id, index, "transfer")
214
+ raise ArgumentError, "debit_account_id and credit_account_id must be distinct at index #{index}" if debit == credit
215
+ amount = required_integer!(tuple, :amount, index, "transfer")
216
+ ledger = required_integer!(tuple, :ledger, index, "transfer")
217
+ code = required_integer!(tuple, :code, index, "transfer")
218
+ validate_range!(amount, 0, UINT128_MAX, :amount, index, "transfer")
219
+ validate_range!(ledger, 1, UINT32_MAX, :ledger, index, "transfer")
220
+ validate_range!(code, 1, UINT16_MAX, :code, index, "transfer")
221
+ end
222
+
223
+ def validate_uint128_id!(value, field, index, record_type)
224
+ validate_range!(value, 1, UINT128_MAX - 1, field, index, record_type)
225
+ end
226
+
227
+ def required_integer!(tuple, field, index, record_type)
228
+ value = tuple[field]
229
+ raise ArgumentError, "#{field} is required for a #{record_type} at index #{index}" unless value.is_a?(Integer)
230
+ value
231
+ end
232
+
233
+ def validate_range!(value, minimum, maximum, field, index, record_type)
234
+ unless value.is_a?(Integer) && value.between?(minimum, maximum)
235
+ raise ArgumentError, "#{field} must be between #{minimum} and #{maximum} for a #{record_type} at index #{index}"
236
+ end
237
+ end
238
+
239
+ def require_transfer_fields!(tuple, index)
240
+ %i[id debit_account_id credit_account_id amount ledger code].each do |field|
241
+ value = tuple[field]
242
+ raise ArgumentError, "#{field} is required for a two-phase transfer at index #{index}" if value.nil?
243
+ raise ArgumentError, "#{field} must be a non-negative integer at index #{index}" unless value.is_a?(Integer) && value >= 0
244
+ end
245
+ end
246
+
247
+ def integer_field(tuple, field, default, index)
248
+ value = tuple[field]
249
+ value = default if value.nil?
250
+ raise ArgumentError, "#{field} must be an integer at index #{index}" unless value.is_a?(Integer)
251
+ value
252
+ end
253
+ private
254
+
255
+ def chunks(batch)
256
+ chunks = []
257
+ current = []
258
+
259
+ linked_groups(batch).each do |group|
260
+ if group.length > batch_size
261
+ raise ArgumentError,
262
+ "linked #{kind} chain has #{group.length} records, exceeding batch_size #{batch_size}"
263
+ end
264
+
265
+ if !current.empty? && current.length + group.length > batch_size
266
+ chunks << current
267
+ current = []
268
+ end
269
+
270
+ current.concat(group)
271
+ end
272
+
273
+ chunks << current unless current.empty?
274
+ chunks
275
+ end
276
+
277
+ def linked_groups(batch)
278
+ groups = []
279
+ current = []
280
+
281
+ batch.each do |tuple|
282
+ current << tuple
283
+ unless linked?(tuple)
284
+ groups << current
285
+ current = []
286
+ end
287
+ end
288
+
289
+ groups << current unless current.empty?
290
+ groups
291
+ end
292
+
293
+ def linked?(tuple)
294
+ flags = tuple.respond_to?(:[]) ? tuple[:flags] : tuple.flags
295
+ !flags.nil? && (Integer(flags) & LINKED_FLAG).positive?
296
+ end
297
+
298
+ def rows
299
+ return [] if ids.nil? || ids.empty?
300
+
301
+ lookup_method = kind == :accounts ? :lookup_accounts : :lookup_transfers
302
+ Array(client.public_send(lookup_method, ids))
303
+ end
304
+
305
+ def build(tuple)
306
+ struct_class = kind == :accounts ? ::TigerBeetle::Account : ::TigerBeetle::Transfer
307
+ struct_class.new(**tuple)
308
+ end
309
+
310
+ def create_succeeded?(result)
311
+ status_class = kind == :accounts ? ::TigerBeetle::CreateAccountStatus : ::TigerBeetle::CreateTransferStatus
312
+ status = result.status
313
+
314
+ [status_class::CREATED, status_class::EXISTS].include?(status)
315
+ end
316
+ end
317
+
318
+ class CommandError < StandardError
319
+ attr_reader :kind, :errors
320
+
321
+ def initialize(kind, errors)
322
+ @kind = kind
323
+ @errors = errors
324
+ super("#{errors.size} #{kind} entries failed: #{errors.inspect}")
325
+ end
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ROM
4
+ module TigerBeetle
5
+ # TigerBeetle::Account#flags and TigerBeetle::Transfer#flags are plain
6
+ # uint16 bitfields. The tigerbeetle gem may or may not expose named
7
+ # constants for these (check `defined?(::TigerBeetle::AccountFlags)` at
8
+ # runtime against whatever version you're on) — these mirror the bit
9
+ # positions documented at https://docs.tigerbeetle.com/reference/account/
10
+ # and https://docs.tigerbeetle.com/reference/transfer/, which are stable
11
+ # across every official client (Go/Java/.NET/Node/Python/Rust).
12
+ #
13
+ # Verify against your installed gem version before relying on this in
14
+ # production — if TigerBeetle::AccountFlags/TransferFlags exist, prefer
15
+ # those over this fallback.
16
+ module Flags
17
+ ACCOUNT = {
18
+ linked: 1 << 0,
19
+ debits_must_not_exceed_credits: 1 << 1,
20
+ credits_must_not_exceed_debits: 1 << 2,
21
+ history: 1 << 3,
22
+ imported: 1 << 4,
23
+ closed: 1 << 5
24
+ }.freeze
25
+
26
+ TRANSFER = {
27
+ linked: 1 << 0,
28
+ pending: 1 << 1,
29
+ post_pending_transfer: 1 << 2,
30
+ void_pending_transfer: 1 << 3,
31
+ balancing_debit: 1 << 4,
32
+ balancing_credit: 1 << 5,
33
+ closing_debit: 1 << 6,
34
+ closing_credit: 1 << 7,
35
+ imported: 1 << 8
36
+ }.freeze
37
+
38
+ def self.account(*names)
39
+ names.sum { |n| ACCOUNT.fetch(n) }
40
+ end
41
+
42
+ def self.transfer(*names)
43
+ names.sum { |n| TRANSFER.fetch(n) }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rom/initializer"
4
+ require "tigerbeetle"
5
+ require "rom/tiger_beetle/dataset"
6
+
7
+ module ROM
8
+ module TigerBeetle
9
+ # ROM::Gateway backed by the `tigerbeetle` gem (native Zig client via FFI).
10
+ #
11
+ # TigerBeetle has exactly two datasets, fixed by the protocol: :accounts
12
+ # and :transfers. There's no arbitrary schema/table creation — this
13
+ # gateway doesn't pretend otherwise.
14
+ #
15
+ # @example basic configuration
16
+ # conf = ROM::Configuration.new(:tiger_beetle, addresses: "3000")
17
+ #
18
+ # class Accounts < ROM::Relation[:tiger_beetle]
19
+ # schema(:accounts, infer: false) do
20
+ # attribute :id, ROM::TigerBeetle::Types::UInt128
21
+ # attribute :ledger, ROM::TigerBeetle::Types::UInt32
22
+ # attribute :code, ROM::TigerBeetle::Types::UInt32
23
+ # end
24
+ # end
25
+ #
26
+ # conf.register_relation(Accounts)
27
+ # rom = ROM.container(conf)
28
+ # rom.relations[:accounts].by_id(100).one
29
+ class Gateway < ROM::Gateway
30
+ extend Initializer
31
+
32
+ KINDS = %i[accounts transfers].freeze
33
+
34
+ # @!attribute [r] addresses
35
+ # @return [String] comma-separated replica addresses, e.g. "3000"
36
+ # or "127.0.0.1:3000,127.0.0.1:3001"
37
+ # NOTE: fully-qualified as ROM::Types — our own ROM::TigerBeetle::Types
38
+ # module (see types.rb) is closer in lexical scope and would otherwise
39
+ # shadow it here, since it also happens to be named `Types`.
40
+ option :addresses, ROM::Types::Strict::String
41
+
42
+ # @!attribute [r] cluster_id
43
+ # @return [Integer]
44
+ option :cluster_id, ROM::Types::Strict::Integer, default: -> { 0 }
45
+
46
+ # Maximum records per create request; configure this to the cluster limit.
47
+ option :batch_size, ROM::Types::Strict::Integer, default: -> { Dataset::DEFAULT_BATCH_SIZE }
48
+
49
+ # @!attribute [r] client
50
+ # @return [TigerBeetle::Client]
51
+ option :client, default: lambda {
52
+ ::TigerBeetle::Client.new(
53
+ cluster_id: cluster_id,
54
+ replica_addresses: addresses
55
+ )
56
+ }
57
+
58
+ # Get a dataset by its kind (:accounts or :transfers)
59
+ #
60
+ # @param kind [Symbol]
61
+ # @return [Dataset]
62
+ # @api public
63
+ def dataset(kind)
64
+ raise ArgumentError, "unknown TigerBeetle dataset #{kind.inspect}, must be one of #{KINDS}" unless KINDS.include?(kind)
65
+
66
+ Dataset.new(client: client, kind: kind, batch_size: batch_size)
67
+ end
68
+ alias_method :[], :dataset
69
+
70
+ # Return true if a dataset with the given kind exists
71
+ #
72
+ # @param kind [Symbol]
73
+ # @return [Boolean]
74
+ # @api public
75
+ def dataset?(kind)
76
+ KINDS.include?(kind)
77
+ end
78
+
79
+ # @api public
80
+ def schema
81
+ KINDS
82
+ end
83
+
84
+ # @api public
85
+ def disconnect
86
+ return if @disconnected
87
+
88
+ @disconnected = true
89
+ return unless client&.respond_to?(:close)
90
+
91
+ client.close
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rom/relation"
4
+
5
+ module ROM
6
+ module TigerBeetle
7
+ class Relation < ROM::Relation
8
+ adapter :tiger_beetle
9
+
10
+ forward :where
11
+
12
+ # Convenience for the common "give me these accounts/transfers by id" case.
13
+ def by_id(*ids)
14
+ where(id: ids.flatten)
15
+ end
16
+
17
+ def history_for(account_id, **options)
18
+ dataset.history_for(account_id, **options)
19
+ end
20
+
21
+ def balances_for(account_id, **options)
22
+ dataset.balances_for(account_id, **options)
23
+ end
24
+ alias account_balances_for balances_for
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/types"
4
+
5
+ module ROM
6
+ module TigerBeetle
7
+ module Types
8
+ include Dry.Types()
9
+
10
+ # Amounts and ids are unsigned 128-bit integers on the wire; Ruby's
11
+ # Integer handles the range natively, this gives schemas a matching
12
+ # range assertion.
13
+ UInt128 = Types::Integer.constrained(gteq: 0, lteq: (2**128) - 1)
14
+ UInt128ID = Types::Integer.constrained(gteq: 1, lt: (2**128) - 1)
15
+ UInt64 = Types::Integer.constrained(gteq: 0, lteq: (2**64) - 1)
16
+ UInt16 = Types::Integer.constrained(gteq: 0, lteq: (2**16) - 1)
17
+ UInt32 = Types::Integer.constrained(gteq: 0, lteq: (2**32) - 1)
18
+
19
+ # Prefer TigerBeetle-generated time-based ids (monotonic, avoids hot
20
+ # B-tree pages under concurrent inserts) over sequential integers or
21
+ # random UUIDs. Wraps ::TigerBeetle.id if the gem exposes it.
22
+ def self.generate_id
23
+ if defined?(::TigerBeetle) && ::TigerBeetle.respond_to?(:id)
24
+ ::TigerBeetle.id
25
+ else
26
+ raise NotImplementedError, "installed tigerbeetle gem doesn't expose .id — generate a monotonic 128-bit id yourself"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ROM
4
+ module TigerBeetle
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rom/core"
4
+ require "rom/tiger_beetle/version"
5
+ require "rom/tiger_beetle/types"
6
+ require "rom/tiger_beetle/flags"
7
+ require "rom/tiger_beetle/gateway"
8
+ require "rom/tiger_beetle/relation"
9
+ require "rom/tiger_beetle/commands"
10
+
11
+ ROM.register_adapter(:tiger_beetle, ROM::TigerBeetle)
metadata ADDED
@@ -0,0 +1,155 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rom-tiger_beetle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Paul Oguda
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rom-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.2'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 5.2.5
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '5.2'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 5.2.5
32
+ - !ruby/object:Gem::Dependency
33
+ name: tigerbeetle
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - '='
37
+ - !ruby/object:Gem::Version
38
+ version: 0.17.9
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - '='
44
+ - !ruby/object:Gem::Version
45
+ version: 0.17.9
46
+ - !ruby/object:Gem::Dependency
47
+ name: bundler
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rake
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rom
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '5.2'
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 5.2.5
84
+ type: :development
85
+ prerelease: false
86
+ version_requirements: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '5.2'
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: 5.2.5
94
+ - !ruby/object:Gem::Dependency
95
+ name: rspec
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ type: :development
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ description: Experimental TigerBeetle support for ROM. Accounts and Transfers only,
109
+ append-only, batch create + id lookup — no arbitrary queries, updates, or deletes,
110
+ matching what the TigerBeetle protocol actually supports.
111
+ email: []
112
+ executables: []
113
+ extensions: []
114
+ extra_rdoc_files: []
115
+ files:
116
+ - CHANGELOG.md
117
+ - LICENSE.txt
118
+ - README.md
119
+ - lib/rom/tiger_beetle.rb
120
+ - lib/rom/tiger_beetle/commands.rb
121
+ - lib/rom/tiger_beetle/commands/create_accounts.rb
122
+ - lib/rom/tiger_beetle/commands/create_pending_transfers.rb
123
+ - lib/rom/tiger_beetle/commands/create_transfers.rb
124
+ - lib/rom/tiger_beetle/commands/post_pending_transfers.rb
125
+ - lib/rom/tiger_beetle/commands/void_pending_transfers.rb
126
+ - lib/rom/tiger_beetle/dataset.rb
127
+ - lib/rom/tiger_beetle/flags.rb
128
+ - lib/rom/tiger_beetle/gateway.rb
129
+ - lib/rom/tiger_beetle/relation.rb
130
+ - lib/rom/tiger_beetle/types.rb
131
+ - lib/rom/tiger_beetle/version.rb
132
+ homepage: https://github.com/kamalogudah/rom-tiger_beetle
133
+ licenses:
134
+ - MIT
135
+ metadata:
136
+ source_code_uri: https://github.com/kamalogudah/rom-tiger_beetle
137
+ bug_tracker_uri: https://github.com/kamalogudah/rom-tiger_beetle/issues
138
+ rdoc_options: []
139
+ require_paths:
140
+ - lib
141
+ required_ruby_version: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '3.3'
146
+ required_rubygems_version: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - ">="
149
+ - !ruby/object:Gem::Version
150
+ version: '0'
151
+ requirements: []
152
+ rubygems_version: 4.0.3
153
+ specification_version: 4
154
+ summary: ROM adapter for TigerBeetle
155
+ test_files: []