xitdb 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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE +21 -0
  4. data/README.md +131 -0
  5. data/docs/benchmarks.md +135 -0
  6. data/docs/core.md +123 -0
  7. data/docs/portable.md +99 -0
  8. data/docs/releasing.md +37 -0
  9. data/docs/ruby-profile-v1.md +226 -0
  10. data/docs/ruby.md +61 -0
  11. data/examples/indexed_users.rb +23 -0
  12. data/examples/portable.rb +62 -0
  13. data/examples/ruby.rb +56 -0
  14. data/lib/xitdb/context.rb +360 -0
  15. data/lib/xitdb/conversion.rb +222 -0
  16. data/lib/xitdb/core/array_list.rb +282 -0
  17. data/lib/xitdb/core/b_tree.rb +546 -0
  18. data/lib/xitdb/core/bytes.rb +181 -0
  19. data/lib/xitdb/core/compaction.rb +172 -0
  20. data/lib/xitdb/core/crc64.rb +22 -0
  21. data/lib/xitdb/core/cursor.rb +201 -0
  22. data/lib/xitdb/core/database.rb +359 -0
  23. data/lib/xitdb/core/file_store.rb +194 -0
  24. data/lib/xitdb/core/format.rb +148 -0
  25. data/lib/xitdb/core/hash_map.rb +371 -0
  26. data/lib/xitdb/core/hasher.rb +49 -0
  27. data/lib/xitdb/core/linked_array_list.rb +85 -0
  28. data/lib/xitdb/core/memory_store.rb +277 -0
  29. data/lib/xitdb/core/offset_map.rb +85 -0
  30. data/lib/xitdb/core/replication.rb +242 -0
  31. data/lib/xitdb/core/sorted_map.rb +361 -0
  32. data/lib/xitdb/core/transaction.rb +159 -0
  33. data/lib/xitdb/core.rb +17 -0
  34. data/lib/xitdb/database.rb +332 -0
  35. data/lib/xitdb/declarations.rb +73 -0
  36. data/lib/xitdb/errors.rb +84 -0
  37. data/lib/xitdb/history.rb +58 -0
  38. data/lib/xitdb/input_guard.rb +39 -0
  39. data/lib/xitdb/profiles/portable.rb +407 -0
  40. data/lib/xitdb/profiles/ruby.rb +173 -0
  41. data/lib/xitdb/revision.rb +42 -0
  42. data/lib/xitdb/ruby/codec.rb +123 -0
  43. data/lib/xitdb/ruby/containers.rb +495 -0
  44. data/lib/xitdb/ruby/key_encoding.rb +60 -0
  45. data/lib/xitdb/ruby/ordered_keys.rb +87 -0
  46. data/lib/xitdb/ruby/scalars.rb +296 -0
  47. data/lib/xitdb/ruby/wire.rb +146 -0
  48. data/lib/xitdb/ruby.rb +46 -0
  49. data/lib/xitdb/snapshot.rb +13 -0
  50. data/lib/xitdb/streams.rb +148 -0
  51. data/lib/xitdb/value_walker.rb +192 -0
  52. data/lib/xitdb/version.rb +5 -0
  53. data/lib/xitdb/views/array.rb +303 -0
  54. data/lib/xitdb/views/base.rb +271 -0
  55. data/lib/xitdb/views/linked_array.rb +18 -0
  56. data/lib/xitdb/views/map.rb +162 -0
  57. data/lib/xitdb/views/set.rb +41 -0
  58. data/lib/xitdb/views/sorted.rb +77 -0
  59. data/lib/xitdb.rb +20 -0
  60. data/sig/base.rbs +76 -0
  61. data/sig/core.rbs +697 -0
  62. data/sig/manifest.yaml +10 -0
  63. data/sig/native.rbs +118 -0
  64. data/sig/public.rbs +427 -0
  65. data/sig/ruby.rbs +337 -0
  66. metadata +137 -0
@@ -0,0 +1,226 @@
1
+ # Ruby profile version 1 wire contract
2
+
3
+ This contract defines the `rb01` profile without changing upstream format version 1.
4
+
5
+ ## File identity
6
+
7
+ The upstream header has hash size 32 and hash identifier `rb01`. Hash indexes use SHA-256 over the canonical key bytes defined below.
8
+
9
+ The physical root is an upstream array list of history entries. Each entry references an encoded logical root map.
10
+
11
+ The portable profile uses its upstream header and layout. It does not inspect or decode Ruby envelopes.
12
+
13
+ ## Scalar notation
14
+
15
+ - W1: `U(n)` is minimal unsigned LEB128 for a nonnegative integer.
16
+ - W2: `B(bytes)` is `U(bytes.bytesize)` followed by those bytes.
17
+ - W3: `T(text)` is `B(text.encode("UTF-8"))`, with valid UTF-8 required.
18
+ - W4: `Z(n)` is one sign byte, then `B(magnitude)`. The sign is 0 for nonnegative and 1 for negative.
19
+ - W5: `Q(r)` is `Z(r.numerator)` followed by `U(r.denominator)`, with a positive, reduced denominator.
20
+
21
+ Integer magnitudes use minimal big-endian bytes. Zero has an empty magnitude and sign 0. A zero integer therefore has `Z(0) = 00 00`.
22
+
23
+ `F64(f)` is the eight bytes from Ruby's `pack("G")`. Multi-byte fixed integers use big-endian order.
24
+
25
+ Every field length must fit inside its enclosing byte payload. Nonminimal integers, truncated fields, invalid flags, and trailing bytes raise `InvalidValueError`.
26
+
27
+ ## Encoded scalar values
28
+
29
+ Upstream byte values carry format tag `rb`, hexadecimal `72 62`. Their payload starts with version byte `01` and a type byte.
30
+
31
+ | Type byte | Ruby value | Remaining payload |
32
+ | --- | --- | --- |
33
+ | `01` | String | `T(encoding.name)`, `B(bytes)` |
34
+ | `02` | Symbol | `T(name.encoding.name)`, `B(name.bytes)` |
35
+ | `03` | Boolean | One byte, `00` for false or `01` for true |
36
+ | `04` | Large Integer | `Z(value)` |
37
+ | `05` | Time | `Q(value.to_r)`, `Z(value.utc_offset)`, one byte for `utc?` |
38
+ | `06` | Date | `Z(value.jd)`, `F64(value.start)` |
39
+ | `07` | DateTime | `Q(value.ajd)`, `Q(value.offset)`, `F64(value.start)` |
40
+ | `08` | BigDecimal | Decimal encoding defined below |
41
+ | `09` | Rational | `Q(value)` |
42
+ | `0a` | Registered custom value | `T(codec_id)`, `B(codec_bytes)` |
43
+
44
+ Integers fitting a native upstream integer slot use that slot. Signed 64-bit values use `int`; remaining unsigned 64-bit values use `uint`.
45
+
46
+ Values outside those ranges use type `04`. Floats use upstream float slots. Stored null uses an occupied upstream `none` slot.
47
+
48
+ Time decoding uses the exact rational instant and recorded UTC offset. The UTC flag distinguishes UTC presentation from a fixed zero offset.
49
+
50
+ The instant retains subnanosecond precision. The offset field accepts integer seconds only; fractional-second UTC offsets raise `UnsupportedValueError`.
51
+
52
+ Date and DateTime decoding restores the recorded calendar start. Their date fields use the public `date` library constructors.
53
+
54
+ Untagged byte values represent explicit binary data. Ruby reads them as frozen ASCII-8BIT strings. Public byte streams address these untagged values.
55
+
56
+ Other format tags remain opaque tagged-byte values. An unknown custom codec retains its complete encoded payload and codec identifier.
57
+
58
+ An unsupported Ruby payload version or builtin type raises `UnsupportedEncodingError`. A registered custom loader never runs during file opening or unrelated traversal.
59
+
60
+ ### Decimal encoding
61
+
62
+ The first decimal byte is 0 for finite, 1 for positive infinity, 2 for negative infinity, or 3 for NaN.
63
+
64
+ Infinite and NaN encodings end after that byte. A finite value adds one sign byte, `T(digits)`, and `Z(exponent)`.
65
+
66
+ Sign 0 is positive; sign 1 is negative. Digits and exponent come from `BigDecimal#split`, whose base must be 10.
67
+
68
+ Digits contain normalized ASCII decimal digits. Zero uses digits `0` and exponent 0. Its sign preserves positive or negative zero.
69
+
70
+ ## Canonical hash keys
71
+
72
+ These bytes determine key equality and hashing. The stored entry separately retains the original encoded key for enumeration.
73
+
74
+ | Prefix | Accepted key | Remaining canonical bytes |
75
+ | --- | --- | --- |
76
+ | `00` | nil | None |
77
+ | `01` | false | None |
78
+ | `02` | true | None |
79
+ | `03` | Integer | `Z(value)` |
80
+ | `04` | Float | `F64(value)`, with signed zero normalized to positive zero |
81
+ | `05` | String | Canonical string body defined below |
82
+ | `06` | Symbol | Canonical string body for `value.to_s` |
83
+
84
+ NaN keys raise `UnsupportedValueError`. Infinite float keys are accepted in hash indexes.
85
+
86
+ A canonical string body is `T(encoding_label)` followed by `B(bytes)`. The encoding label is empty for every empty string.
87
+
88
+ For nonempty ASCII-only strings with an ASCII-compatible encoding, the label is also empty. Other strings use their canonical `Encoding#name`.
89
+
90
+ This preserves Ruby's empty-string equality across encodings. It also preserves the distinction between non-ASCII binary and UTF-8 strings.
91
+
92
+ String and Symbol prefixes remain distinct. Integer and Float prefixes remain distinct.
93
+
94
+ Golden vectors include:
95
+
96
+ | Input | Hexadecimal canonical bytes |
97
+ | --- | --- |
98
+ | nil | `00` |
99
+ | false | `01` |
100
+ | true | `02` |
101
+ | 0 | `03 00 00` |
102
+ | 1 | `03 00 01 01` |
103
+ | -1 | `03 01 01 01` |
104
+ | Empty String in any encoding | `05 00 00` |
105
+ | ASCII String `name` | `05 00 04 6e 61 6d 65` |
106
+ | Symbol `name` | `06 00 04 6e 61 6d 65` |
107
+
108
+ The decoder does not reconstruct keys from canonical bytes. Entry records retain their original Ruby representation.
109
+
110
+ ## Container envelopes
111
+
112
+ Each envelope is an upstream array list with the exact fields shown below. Marker fields are upstream unsigned integer slots.
113
+
114
+ Encoded child values can be native scalar slots, encoded scalar bytes, or container envelopes. Internal indexes are ordinary upstream containers.
115
+
116
+ | Marker | Logical type | Envelope fields |
117
+ | --- | --- | --- |
118
+ | `0x52420101` | Array | `[marker, array_list]` |
119
+ | `0x52420102` | Linked array | `[marker, linked_array_list]` |
120
+ | `0x52420103` | Ordered map | `[marker, next_id, lookup, order]` |
121
+ | `0x52420104` | Ordered set | `[marker, next_id, lookup, order]` |
122
+ | `0x52420105` | Sorted map | `[marker, key_kind, sorted_map]` |
123
+ | `0x52420106` | Sorted set | `[marker, key_kind, sorted_map]` |
124
+
125
+ Arrays and linked arrays contain encoded children. Their envelopes do not require materializing those children.
126
+
127
+ Unexpected markers, field counts, or physical collection kinds raise `InvalidValueError`. The core remains able to inspect the physical values.
128
+
129
+ ### Ordered maps and sets
130
+
131
+ `next_id` is an upstream unsigned integer starting at zero. `lookup` is a counted upstream hash map. `order` is an upstream sorted map.
132
+
133
+ A lookup entry uses `SHA256(canonical_key_bytes)` as its hash. Its physical key is the canonical byte string.
134
+
135
+ Its physical value is a three-element upstream array list: `[encoded_key, encoded_value, insertion_id]`. Sets store occupied null as `encoded_value`.
136
+
137
+ An order entry maps the eight-byte big-endian insertion identifier to the 32-byte lookup hash. Both indexes reference the same logical entry.
138
+
139
+ Insertion creates one entry and increments `next_id`. Updating an equal key retains its original encoded key and insertion identifier.
140
+
141
+ Deletion removes both index entries. Reinsertion receives a fresh identifier. Clearing resets both indexes and `next_id`.
142
+
143
+ When `next_id` equals `2**64 - 1`, another insertion raises `RangeError`. Updates and deletions remain valid.
144
+
145
+ The logical size comes from the counted lookup map. Iteration follows the order index and resolves its referenced entries.
146
+
147
+ ### Sorted maps and sets
148
+
149
+ Key kind is 1 for String, 2 for Integer, 3 for Float, or 4 for Time.
150
+
151
+ A sorted map stores order-preserving key bytes and a two-element record: `[encoded_original_key, encoded_value]`.
152
+
153
+ A sorted set also uses an upstream sorted map. Its value is the encoded original member. This retains representation beyond the comparison key.
154
+
155
+ Updating an equal comparison key retains its original encoded key or member. Every operation uses the declared key kind.
156
+
157
+ ## Ordered key encoding
158
+
159
+ Ordered encodings use unsigned byte comparison. They contain no locale-dependent data and do not use Ruby's randomized hash.
160
+
161
+ ### Integers
162
+
163
+ For nonnegative `n`, define `O(n)` as an eight-byte magnitude length followed by minimal big-endian magnitude bytes.
164
+
165
+ `O(0)` has length zero and no magnitude bytes. The length field is an unsigned 64-bit integer.
166
+
167
+ Ordered signed integers use prefix `00` for negative, `01` for zero, and `02` for positive.
168
+
169
+ Negative values append the bytewise complement of `O(abs(n))`. Positive values append `O(n)`. Zero ends after its prefix.
170
+
171
+ These encodings are prefix-free and compare in numeric order. Magnitude lengths exceeding the physical file range raise `RangeError`.
172
+
173
+ ### Floats
174
+
175
+ Reject NaN. Normalize either signed zero to positive zero. Read the IEEE-754 bits as an unsigned 64-bit integer.
176
+
177
+ For negative values, complement all 64 bits. For nonnegative values, toggle the sign bit. Write the resulting eight bytes big-endian.
178
+
179
+ This places negative infinity first and positive infinity last. It gives either zero representation one lookup key.
180
+
181
+ ### Strings
182
+
183
+ String keys must convert to valid UTF-8. Their ordered bytes are the resulting UTF-8 bytes without a length prefix.
184
+
185
+ The entry record retains the original encoding and bytes. Invalid or unconvertible keys raise `UnsupportedEncodingError`.
186
+
187
+ This comparison deliberately uses UTF-8 text order. Ordinary Ruby hash-key encoding retains its separate equality contract.
188
+
189
+ ### Exact time instants
190
+
191
+ Time keys compare `value.to_r`, including fractions smaller than a nanosecond. Their comparison ignores presentation offsets.
192
+
193
+ For nonnegative rational `x`, define a prefix-free encoding `R(x)`:
194
+
195
+ 1. Compute `q, remainder = x.numerator.divmod(x.denominator)`.
196
+ 2. Write `O(q)`.
197
+ 3. For zero remainder, append byte `00` and stop.
198
+ 4. Otherwise, append byte `01` and the complement of `R(x.denominator / remainder)`.
199
+
200
+ The division in step 4 denotes an exact Rational. An iterative continued-fraction implementation avoids Ruby stack growth.
201
+
202
+ For signed rational instants, use the signed prefixes from integer encoding. Negative instants append the complement of `R(abs(x))`.
203
+
204
+ Positive instants append `R(x)`. Zero ends after its prefix. Prefix-free child encodings make byte complementation reverse their order.
205
+
206
+ This requires work proportional to the continued-fraction representation. It does not emit one byte per denominator unit.
207
+
208
+ ## Ownership and loader behavior
209
+
210
+ View reads return frozen scalar values. Materialization creates independent values and mutable containers and strings where Ruby permits mutation.
211
+
212
+ Custom loaders receive a frozen copy of their payload. They must return a fresh owned value, without shared mutable state between calls.
213
+
214
+ The profile freezes the returned object's outer state for view reads. A custom codec owns the immutability of references inside that value.
215
+
216
+ Materialization invokes the loader independently. No implicit constant lookup or arbitrary object loader interprets stored class names.
217
+
218
+ ## Required fixtures
219
+
220
+ Golden fixtures cover every scalar type, every container envelope, key equality, signed zero, and unknown custom codecs.
221
+
222
+ Ordered-key fixtures compare encoded byte order with numeric or temporal order. They include negative values, huge integers, infinities, and exact subnanosecond instants.
223
+
224
+ Malformed fixtures cover truncated lengths, nonminimal integers, invalid encodings, invalid fields, and conflicting index records.
225
+
226
+ Changes to canonical keys, payload interpretation, or container layouts require a different profile identifier. Version-one readers never infer a changed layout from data shape.
data/docs/ruby.md ADDED
@@ -0,0 +1,61 @@
1
+ # Ruby profile
2
+
3
+ `require "xitdb/ruby"` provides `XitDB::Ruby.open(path, readonly: false, lock_timeout: 5.0, codecs: [])` and `XitDB::Ruby.memory(codecs: [])`.
4
+ Configuration belongs to each handle. The file identifier `rb01` selects SHA-256 over canonical typed keys and version-one container envelopes.
5
+ The [portable guide](portable.md) defines the shared transaction, snapshot, history, ownership, stream, and view contracts.
6
+
7
+ ## Native values and keys
8
+
9
+ Supported exact builtin value classes include nil, booleans, Integer, Float, String, Symbol, Hash, Array, Set, Time, Date, DateTime, BigDecimal, and Rational.
10
+ Integers have arbitrary size. Strings preserve bytes and encoding names. Unknown encodings raise `UnsupportedEncodingError` on decoding.
11
+ Time preserves its exact rational instant, including subnanosecond fractions, UTC flag, and integer UTC offset.
12
+ The offset wire field is an integer: fractional-second UTC offsets raise `UnsupportedValueError`. Instant precision is unchanged.
13
+ Named timezone rules are not stored. Date and DateTime preserve their calendar start and date or instant.
14
+ BigDecimal preserves numeric value and signed zero. Rational preserves numerator and denominator.
15
+ Float values preserve IEEE representations, including infinities and NaN outside keys.
16
+
17
+ Ordinary keys and set members accept exact NilClass, TrueClass, FalseClass, Integer, Float, String, and Symbol instances.
18
+ Symbols and Strings remain distinct, as do Integer and Float keys. Float keys normalize signed zero and reject NaN.
19
+ ASCII-compatible String keys with equal ASCII bytes compare equally. Non-ASCII keys retain encoding-sensitive equality.
20
+ Ordinary keys never use custom codecs. Containers, temporal values, and custom objects cannot be ordinary keys.
21
+
22
+ Maps and sets preserve insertion order. Updating retains position; deleting and reinserting moves an entry to the end.
23
+ Native containers select the ordinary Ruby-profile representation. Counts are available without scanning members.
24
+ Hash defaults, default procs, identity comparison, singleton behavior, executable objects, and cyclic inputs are unsupported.
25
+ Scalar and container subclasses need an explicit value codec. Unregistered subclasses reject before overridden methods execute.
26
+
27
+ ## Sorted collections
28
+
29
+ Declare `XitDB.sorted_map(key: :integer)` or `XitDB.sorted_set(key: :string)` explicitly.
30
+ Key kinds are `:string`, `:integer`, `:float`, and `:time`.
31
+ Strings use valid UTF-8 byte order. Integers include arbitrary-size values; Float rejects NaN.
32
+ Time keys compare exact rational instants, independent of presentation offset.
33
+ `rank`, inclusive `from`, `from_index`, and `page` use the storage tree. Snapshot pagination remains stable through later commits.
34
+
35
+ ## Custom values
36
+
37
+ ```ruby
38
+ Money = Struct.new(:cents)
39
+ codec = XitDB::Ruby::Codec.new(
40
+ id: "example/money/v1", type: Money,
41
+ dump: ->(money) { money.cents.to_s },
42
+ load: ->(bytes) { Money.new(Integer(bytes)) }
43
+ )
44
+ XitDB::Ruby.memory(codecs: [codec]) do |db|
45
+ db.transaction { |root| root[:price] = Money.new(125) }
46
+ p db.snapshot[:price].cents # 125
47
+ end
48
+ ```
49
+
50
+ Dumps return byte Strings. Use stable, schema-versioned identifiers. Duplicate IDs, overlapping registrations, and builtin overrides raise `CodecConfigurationError`.
51
+ Register subclass values explicitly; registering codecs never extends key support.
52
+ Loader input is a frozen copy. Return a fresh owned value for each call.
53
+ The profile freezes the returned object's outer state. The codec must control mutable references inside that object.
54
+ `to_ruby` invokes loaders independently. Unknown codecs return immutable `EncodedValue` objects that retain complete payload bytes.
55
+ Opening and unrelated traversal never load classes or run arbitrary object deserialization.
56
+
57
+ Callbacks can read transaction views. Coercion, dump, load, and conversion callbacks cannot start a second logical mutation during one Core transaction's active mutation.
58
+ The guard applies across Context instances; nested writes raise `TransactionStateError` before changing state.
59
+ Callback failure aborts the affected assignment. Byte-stream callbacks retain their separate non-overlapping sibling-write behavior.
60
+
61
+ See [native workflows](../examples/ruby.rb) and the [wire contract](ruby-profile-v1.md).
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "xitdb"
4
+ require "json"
5
+
6
+ XitDB.memory do |db|
7
+ db.transaction do |root|
8
+ root["users"] = {}
9
+ root["by_name"] = XitDB.sorted_map
10
+ [%w[1 Ada], %w[2 Grace]].each do |id, name|
11
+ root["users"][id] = { "name" => name }
12
+ root["by_name"][name] = id
13
+ end
14
+ end
15
+ snapshot = db.snapshot
16
+ names = snapshot["by_name"].page(offset: 0, limit: 2).map do |_name, id|
17
+ snapshot["users"][id]["name"]
18
+ end
19
+ raise "rank differs" unless snapshot["by_name"].rank("Grace") == 1
20
+ raise "range differs" unless snapshot["by_name"].from("Grace").first == %w[Grace 2]
21
+
22
+ puts JSON.generate(names: names)
23
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "xitdb"
4
+ require "json"
5
+ require "tmpdir"
6
+ require "stringio"
7
+
8
+ Dir.mktmpdir("xitdb-portable-") do |directory|
9
+ XitDB.open(File.join(directory, "source.xit"), hash: :sha256) do |db|
10
+ db.transaction do |root|
11
+ root["profile"] = { "name" => "Ada", "visits" => 0 }
12
+ root["tags"] = Set["ruby", "storage"]
13
+ root["events"] = XitDB.linked_array
14
+ root["events"].push("created", "visited")
15
+ root["counted"] = XitDB.map(counted: true)
16
+ root["counted"]["n"] = XitDB.uint((2**64) - 1)
17
+ root["opaque"] = XitDB.bytes("payload", format_tag: "zz")
18
+ root.write_bytes("attachment") do |io|
19
+ io.write("abc")
20
+ io.write("def")
21
+ end
22
+ end
23
+ before = db.snapshot
24
+ db.transaction { |root| root["profile"]["visits"] += 1 }
25
+ raise "snapshot changed" unless before.dig("profile", "visits").zero?
26
+ raise "revision changed" unless db.snapshot(revision: before.revision).dig("profile", "visits").zero?
27
+ raise "stream differs" unless db.snapshot.open_bytes("attachment", &:read) == "abcdef"
28
+ raise "linked slice differs" unless db.snapshot["events"].split(1).last.to_ruby == ["visited"]
29
+ raise "history missing" unless db.history.fetch(0).dig("profile", "visits").zero?
30
+
31
+ db.revert(before)
32
+ raise "revert differs" unless db.snapshot.dig("profile", "visits").zero?
33
+
34
+ db.compact(to: File.join(directory, "compact.xit")).close
35
+ XitDB.open(File.join(directory, "compact.xit"), readonly: true) do |compact|
36
+ raise "compaction differs" unless compact.snapshot.dig("profile", "name") == "Ada"
37
+ end
38
+
39
+ # Bootstrap a separate replica, then send one incremental update.
40
+ full = StringIO.new("".b)
41
+ finish = db.core.replica_state.committed_size
42
+ db.core.read_committed(0, finish, full)
43
+ full.rewind
44
+ replica = XitDB::Core::Database.copy_replica(to: File.join(directory, "replica.xit"), source_io: full,
45
+ length: finish)
46
+ begin
47
+ db.transaction { |root| root["profile"]["visits"] = 2 }
48
+ start = db.core.replication_start(replica.replica_state)
49
+ raise "full staging required" if start.zero?
50
+
51
+ finish = db.core.replica_state.committed_size
52
+ delta = StringIO.new("".b)
53
+ db.core.read_committed(start, finish, delta)
54
+ delta.rewind
55
+ replica.apply_replicated(start, delta, finish - start)
56
+ raise "replica differs" unless replica.replica_state == db.core.replica_state
57
+ ensure
58
+ replica.close
59
+ end
60
+ puts JSON.generate(name: db.snapshot.dig("profile", "name"), visits: db.snapshot.dig("profile", "visits"))
61
+ end
62
+ end
data/examples/ruby.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "xitdb/ruby"
4
+ require "json"
5
+ require "tmpdir"
6
+ require "bigdecimal"
7
+ require "date"
8
+
9
+ Money = Struct.new(:cents)
10
+ codec = XitDB::Ruby::Codec.new(id: "example/money/v1", type: Money,
11
+ dump: ->(money) { money.cents.to_s },
12
+ load: ->(bytes) { Money.new(Integer(bytes)) })
13
+
14
+ Dir.mktmpdir("xitdb-ruby-") do |directory|
15
+ XitDB::Ruby.memory(codecs: [codec]) do |db|
16
+ instant = Time.at(Rational(1, 10**12)).utc
17
+ db.transaction do |root|
18
+ root[:enabled] = true
19
+ root[:values] = [2**200, Rational(2, 3), BigDecimal("1.25"), Date.new(2026, 11, 4),
20
+ DateTime.new(2026, 11, 4), instant, "é".encode("UTF-16LE"), Money.new(125)]
21
+ root[:keys] = { name: "symbol", "name" => "string", 1 => "integer", 1.0 => "float" }
22
+ root[:members] = Set[:second, :first]
23
+ root[:times] = XitDB.sorted_map(key: :time)
24
+ root[:times][instant] = "created"
25
+ end
26
+ snapshot = db.snapshot
27
+ raise "exact instant lost" unless snapshot[:values][5].to_r == instant.to_r
28
+ raise "codec differs" unless snapshot[:values][7].cents == 125
29
+ raise "key identity lost" unless snapshot[:keys].size == 4
30
+ raise "set order lost" unless snapshot[:members].to_a == %i[second first]
31
+ raise "time lookup differs" unless snapshot[:times].from(instant).first.last == "created"
32
+
33
+ XitDB::Ruby.memory(codecs: [codec]) do |copy|
34
+ copy.transaction { |root| root.context.copy_from(snapshot[:values], at: [:copied]) }
35
+ raise "cross-file copy differs" unless copy.snapshot[:copied][0] == 2**200
36
+ end
37
+ # Convert a representable subset with an explicit key/value transform.
38
+ XitDB::Ruby.memory do |source|
39
+ source.transaction { |root| root[:enabled] = true }
40
+ portable = source.convert(to: File.join(directory, "portable.xit"), profile: :portable, history: :latest,
41
+ transform: lambda { |value:, role:, path:| # rubocop:disable Lint/UnusedBlockArgument -- Keep the callback's public keyword contract.
42
+ if role == :key
43
+ value.to_s
44
+ else
45
+ (value == true ? 1 : value)
46
+ end
47
+ })
48
+ begin
49
+ raise "conversion differs" unless portable.snapshot["enabled"] == 1
50
+ ensure
51
+ portable.close
52
+ end
53
+ end
54
+ puts JSON.generate(enabled: snapshot[:enabled])
55
+ end
56
+ end