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 +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +22 -0
- data/README.md +208 -0
- data/lib/rom/tiger_beetle/commands/create_accounts.rb +22 -0
- data/lib/rom/tiger_beetle/commands/create_pending_transfers.rb +17 -0
- data/lib/rom/tiger_beetle/commands/create_transfers.rb +17 -0
- data/lib/rom/tiger_beetle/commands/post_pending_transfers.rb +17 -0
- data/lib/rom/tiger_beetle/commands/void_pending_transfers.rb +17 -0
- data/lib/rom/tiger_beetle/commands.rb +7 -0
- data/lib/rom/tiger_beetle/dataset.rb +328 -0
- data/lib/rom/tiger_beetle/flags.rb +47 -0
- data/lib/rom/tiger_beetle/gateway.rb +95 -0
- data/lib/rom/tiger_beetle/relation.rb +27 -0
- data/lib/rom/tiger_beetle/types.rb +31 -0
- data/lib/rom/tiger_beetle/version.rb +7 -0
- data/lib/rom/tiger_beetle.rb +11 -0
- metadata +155 -0
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,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: []
|