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,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XitDB
4
+ module Core
5
+ # A persistent hash trie with optional entry counts.
6
+ class HashMap
7
+ BLOCK_SIZE = 16 * Slot::SIZE
8
+ EMPTY_BLOCK = ("\0".b * BLOCK_SIZE).freeze
9
+ TAGS = %i[hash_map hash_set counted_hash_map counted_hash_set].freeze
10
+
11
+ attr_reader :cursor
12
+
13
+ def initialize(cursor, counted: false, set: false)
14
+ @cursor = cursor
15
+ @tag = collection_tag(counted, set)
16
+ slot = cursor.slot
17
+ unless slot.tag == :none || (TAGS.include?(slot.tag) && counted == counted_tag?(slot.tag))
18
+ raise InvalidValueError, "slot does not contain the requested hash collection"
19
+ end
20
+
21
+ @tag = slot.tag unless slot.tag == :none
22
+ return unless slot.tag == :none && cursor.transaction
23
+
24
+ mutate { |transaction| publish(writable_root(transaction)) }
25
+ end
26
+
27
+ def self.from_cursor(cursor)
28
+ new(cursor, counted: %i[counted_hash_map counted_hash_set].include?(cursor.slot.tag))
29
+ end
30
+
31
+ def get(hash_bytes)
32
+ entry(hash_bytes)&.last
33
+ end
34
+
35
+ def key(hash_bytes)
36
+ entry(hash_bytes)&.first
37
+ end
38
+
39
+ def include?(hash_bytes)
40
+ !entry(hash_bytes).nil?
41
+ end
42
+
43
+ def size
44
+ start = root_pointer
45
+ return 0 unless start
46
+
47
+ counted? ? Format.unsigned(read(start, 8)) : each_entry.count
48
+ end
49
+
50
+ def put(hash_bytes, key:, value:)
51
+ hash_bytes = validate_hash(hash_bytes)
52
+ validate_slot(key)
53
+ validate_slot(value)
54
+ write_entry(hash_bytes, key: key, value: value)
55
+ self
56
+ end
57
+
58
+ def add(hash_bytes, key:)
59
+ hash_bytes = validate_hash(hash_bytes)
60
+ validate_slot(key)
61
+ write_entry(hash_bytes, key: key, value: nil)
62
+ self
63
+ end
64
+
65
+ def cursor_at(hash_bytes, create: false)
66
+ hash_bytes = validate_hash(hash_bytes)
67
+ value = get(hash_bytes)
68
+ if value.nil? && create
69
+ put(hash_bytes, key: Slot.new, value: Slot.new)
70
+ value = get(hash_bytes)
71
+ end
72
+ return nil unless value
73
+
74
+ cursor.hash_child(hash_bytes.freeze, value)
75
+ end
76
+
77
+ def replace_child(hash_bytes, slot, owner: nil, collection: false, propagate: true)
78
+ hash_bytes = validate_hash(hash_bytes)
79
+ validate_slot(slot)
80
+ raise ExpiredViewError, "hash entry was removed" unless include?(hash_bytes)
81
+
82
+ write_entry(hash_bytes, key: nil, value: slot, owner: owner, collection: collection, propagate: propagate)
83
+ self
84
+ end
85
+
86
+ def remove(hash_bytes) # rubocop:disable Naming/PredicateMethod -- This public method removes an entry.
87
+ hash_bytes = validate_hash(hash_bytes)
88
+ writable!
89
+ return false unless include?(hash_bytes)
90
+
91
+ mutate do |transaction|
92
+ start = writable_root(transaction)
93
+ pointer = start + count_bytes
94
+ depth = 0
95
+ # @type var path: Array[[Integer, String, Integer]]
96
+ path = []
97
+ loop do
98
+ block = read(pointer, BLOCK_SIZE)
99
+ index = nibble(hash_bytes, depth)
100
+ slot = parse_slot(Format.slice(block, index * Slot::SIZE, Slot::SIZE))
101
+ path << [pointer, block, index]
102
+ break unless slot.tag == :index
103
+
104
+ pointer = slot.value
105
+ depth += 1
106
+ end
107
+ collapse_path(transaction, start, path)
108
+ adjust_count(transaction, start, -1)
109
+ publish(start)
110
+ cursor.invalidate_children(index: hash_bytes)
111
+ end
112
+ true
113
+ end
114
+
115
+ def each_entry
116
+ return enum_for(:each_entry) unless block_given?
117
+
118
+ start = root_pointer
119
+ return self unless start
120
+
121
+ # @type var stack: Array[[Integer, Integer]]
122
+ stack = [[start + count_bytes, 0]]
123
+ until stack.empty?
124
+ pointer, depth = stack.pop || raise(CorruptionError, "missing hash branch")
125
+ raise CorruptionError, "hash trie exceeds hash width" if depth >= hash_size * 2
126
+
127
+ block = read(pointer, BLOCK_SIZE)
128
+ # @type var children: Array[[Integer, Integer]]
129
+ children = []
130
+ 16.times do |i|
131
+ slot = parse_slot(Format.slice(block, i * Slot::SIZE, Slot::SIZE))
132
+ if slot.tag == :index
133
+ children << [slot.value, depth + 1]
134
+ elsif slot.tag == :kv_pair
135
+ hash, key, value = read_record(slot)
136
+ yield hash, key, value
137
+ elsif !slot.empty?
138
+ raise CorruptionError, "invalid hash trie entry"
139
+ end
140
+ end
141
+ stack.concat(children.reverse)
142
+ end
143
+ self
144
+ end
145
+
146
+ private
147
+
148
+ def collection_tag(counted, set)
149
+ if counted
150
+ set ? :counted_hash_set : :counted_hash_map
151
+ else
152
+ set ? :hash_set : :hash_map
153
+ end
154
+ end
155
+
156
+ def collapsible?(pointer, start, entries)
157
+ pointer != start + count_bytes && entries.size <= 1 && entries.none? { |slot| slot.tag == :index }
158
+ end
159
+
160
+ def collapse_path(transaction, start, path)
161
+ replacement = Slot.new
162
+ path.reverse_each do |old_pointer, block, index|
163
+ block[index * Slot::SIZE, Slot::SIZE] = replacement.to_bytes
164
+ entries = Array.new(16) { |i| parse_slot(Format.slice(block, i * Slot::SIZE, Slot::SIZE)) }.reject(&:empty?)
165
+ if collapsible?(old_pointer, start, entries)
166
+ replacement = entries.first || Slot.new
167
+ else
168
+ new_pointer = old_pointer < transaction.mutable_start ? transaction.append(block) : old_pointer
169
+ transaction.write_at(new_pointer, block)
170
+ replacement = Slot.new(tag: :index, value: new_pointer)
171
+ end
172
+ end
173
+ end
174
+
175
+ def counted_tag?(tag)
176
+ %i[counted_hash_map counted_hash_set].include?(tag)
177
+ end
178
+
179
+ def counted?
180
+ counted_tag?(@tag)
181
+ end
182
+
183
+ def count_bytes
184
+ counted? ? 8 : 0
185
+ end
186
+
187
+ def hash_size
188
+ cursor.database.header.hash_size
189
+ end
190
+
191
+ def validate_hash(hash)
192
+ raise TypeError, "hash must be a String" unless hash.is_a?(String)
193
+ raise ArgumentError, "hash has incorrect width" unless hash.bytesize == hash_size && hash_size.positive?
194
+
195
+ hash.b
196
+ end
197
+
198
+ def validate_slot(slot)
199
+ raise TypeError, "expected Slot" unless slot.is_a?(Slot)
200
+ raise CorruptionError, "unknown hash record slot tag" unless Slot::TAGS.include?(slot.tag)
201
+ raise InvalidValueError, "index slot is internal to hash branches" if slot.tag == :index
202
+ end
203
+
204
+ def nibble(hash, depth)
205
+ raise CorruptionError, "hash trie exceeds hash width" if depth >= hash_size * 2
206
+
207
+ byte = hash.getbyte(hash_size - 1 - (depth / 2))
208
+ raise CorruptionError, "hash trie exceeds hash width" unless byte
209
+
210
+ (byte >> (depth % 2 * 4)) & 15
211
+ end
212
+
213
+ def root_pointer
214
+ slot = cursor.slot
215
+ return nil if slot.tag == :none
216
+ raise InvalidValueError, "slot does not contain the requested hash collection" unless slot.tag == @tag
217
+
218
+ read(slot.value, count_bytes + BLOCK_SIZE)
219
+ slot.value
220
+ end
221
+
222
+ def read(offset, length)
223
+ cursor.database.read_at(offset, length, bound: cursor.bound)
224
+ end
225
+
226
+ def parse_slot(bytes)
227
+ slot = Slot.parse(bytes)
228
+ raise CorruptionError, "unknown hash slot tag" unless Slot::TAGS.include?(slot.tag)
229
+
230
+ slot
231
+ end
232
+
233
+ def read_record(slot)
234
+ bytes = read(slot.value, hash_size + (Slot::SIZE * 2))
235
+ key = parse_slot(Format.slice(bytes, hash_size, Slot::SIZE))
236
+ value = parse_slot(Format.slice(bytes, hash_size + Slot::SIZE, Slot::SIZE))
237
+ raise CorruptionError, "index slot in hash record" if key.tag == :index || value.tag == :index
238
+
239
+ [Format.slice(bytes, 0, hash_size).freeze, key, value]
240
+ end
241
+
242
+ def entry(hash)
243
+ hash = validate_hash(hash)
244
+ start = root_pointer
245
+ return nil unless start
246
+
247
+ pointer = start + count_bytes
248
+ depth = 0
249
+ loop do
250
+ block = read(pointer, BLOCK_SIZE)
251
+ slot = parse_slot(Format.slice(block, nibble(hash, depth) * Slot::SIZE, Slot::SIZE))
252
+ return nil if slot.empty?
253
+
254
+ if slot.tag == :kv_pair
255
+ stored_hash, key, value = read_record(slot)
256
+ return stored_hash == hash ? [key, value] : nil
257
+ end
258
+ raise CorruptionError, "invalid hash trie entry" unless slot.tag == :index
259
+
260
+ pointer = slot.value
261
+ depth += 1
262
+ end
263
+ end
264
+
265
+ def writable!
266
+ cursor.slot
267
+ raise ReadOnlyError, "hash collection is read-only" unless cursor.transaction
268
+ end
269
+
270
+ def mutate
271
+ writable!
272
+ transaction = cursor.transaction
273
+ raise ReadOnlyError, "hash collection is read-only" unless transaction
274
+
275
+ checkpoint = transaction.checkpoint
276
+ begin
277
+ yield transaction
278
+ rescue Exception # rubocop:disable Lint/RescueException -- Cleanup must also preserve nonstandard exceptions.
279
+ transaction.restore(checkpoint)
280
+ raise
281
+ else
282
+ transaction.release_checkpoint(checkpoint)
283
+ end
284
+ end
285
+
286
+ def writable_root(transaction)
287
+ start = root_pointer
288
+ return transaction.append(("\0".b * count_bytes) + EMPTY_BLOCK) unless start
289
+
290
+ bytes = read(start, count_bytes + BLOCK_SIZE)
291
+ start < transaction.mutable_start ? transaction.append(bytes) : start
292
+ end
293
+
294
+ def publish(start, propagate: true)
295
+ cursor.update_collection(Slot.new(tag: @tag, value: start), propagate: propagate)
296
+ end
297
+
298
+ def adjust_count(transaction, start, delta)
299
+ return unless counted?
300
+
301
+ count = Format.unsigned(read(start, 8)) + delta
302
+ Format.integer(count, 0..Format::UINT64_MAX)
303
+ transaction.write_at(start, [count].pack("Q>"))
304
+ end
305
+
306
+ def write_entry(hash, key:, value:, owner: nil, collection: false, propagate: true)
307
+ mutate do |transaction|
308
+ start = writable_root(transaction)
309
+ pointer = start + count_bytes
310
+ depth = 0
311
+ loop do
312
+ position = pointer + (nibble(hash, depth) * Slot::SIZE)
313
+ slot = parse_slot(read(position, Slot::SIZE))
314
+ if slot.empty? || slot.tag == :kv_pair
315
+ next_pointer = write_leaf(transaction, position, slot, hash, depth, start, key, value)
316
+ break unless next_pointer
317
+ elsif slot.tag == :index
318
+ bytes = read(slot.value, BLOCK_SIZE)
319
+ next_pointer = slot.value < transaction.mutable_start ? transaction.append(bytes) : slot.value
320
+ else
321
+ raise CorruptionError, "invalid hash trie entry"
322
+ end
323
+ transaction.write_at(position, Slot.new(tag: :index, value: next_pointer).to_bytes)
324
+ pointer = next_pointer
325
+ depth += 1
326
+ end
327
+ publish(start, propagate: propagate)
328
+ cursor.invalidate_children(index: hash, except: owner) unless collection || value.nil?
329
+ end
330
+ end
331
+
332
+ def write_leaf(transaction, position, slot, hash, depth, start, key, value)
333
+ stored_hash, stored_key, stored_value = slot.empty? ? [nil, Slot.new, Slot.new] : read_record(slot)
334
+ if stored_hash.nil? || stored_hash == hash
335
+ next_key = stored_key.empty? && key ? key : stored_key
336
+ next_value = value || stored_value
337
+ record = transaction.append(hash.b + next_key.to_bytes + next_value.to_bytes)
338
+ transaction.write_at(position, Slot.new(tag: :kv_pair, value: record).to_bytes)
339
+ adjust_count(transaction, start, 1) if stored_hash.nil?
340
+ return nil
341
+ end
342
+
343
+ next_pointer = transaction.append(EMPTY_BLOCK)
344
+ previous_position = next_pointer + (nibble(stored_hash, depth + 1) * Slot::SIZE)
345
+ transaction.write_at(previous_position, slot.to_bytes)
346
+ next_pointer
347
+ end
348
+ end
349
+
350
+ # A hash trie that stores keys without values.
351
+ class HashSet < HashMap
352
+ def initialize(cursor, counted: false)
353
+ super(cursor, counted: counted, set: true)
354
+ end
355
+ end
356
+
357
+ # A hash trie with a stored entry count.
358
+ class CountedHashMap < HashMap
359
+ def initialize(cursor)
360
+ super(cursor, counted: true)
361
+ end
362
+ end
363
+
364
+ # A hash set with a stored entry count.
365
+ class CountedHashSet < HashSet
366
+ def initialize(cursor)
367
+ super(cursor, counted: true)
368
+ end
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module XitDB
6
+ module Core
7
+ # Validates the identity and width of a digest implementation.
8
+ class Hasher
9
+ attr_reader :id, :size
10
+
11
+ def initialize(id:, size:, &digest)
12
+ raise TypeError, "id must be a String" unless id.is_a?(String)
13
+ raise ArgumentError, "id must contain four bytes" unless id.bytesize == 4
14
+ raise ArgumentError, "digest block is required" unless digest
15
+
16
+ @id = id.b.freeze
17
+ @size = Format.integer(size, 1..65_535)
18
+ @digest = digest
19
+ freeze
20
+ end
21
+
22
+ def digest(bytes)
23
+ raise TypeError, "bytes must be a String" unless bytes.is_a?(String)
24
+
25
+ result = @digest.call(bytes.b.freeze)
26
+ raise TypeError, "digest must return a String" unless result.is_a?(String)
27
+ raise ArgumentError, "digest has incorrect width" unless result.bytesize == size
28
+
29
+ result.b.freeze
30
+ end
31
+
32
+ def self.sha1
33
+ new(id: "sha1", size: 20) { |bytes| Digest::SHA1.digest(bytes) }
34
+ end
35
+
36
+ def self.sha256
37
+ new(id: "sha2", size: 32) { |bytes| Digest::SHA256.digest(bytes) }
38
+ end
39
+
40
+ def self.sha384
41
+ new(id: "sha2", size: 48) { |bytes| Digest::SHA384.digest(bytes) }
42
+ end
43
+
44
+ def self.sha512
45
+ new(id: "sha2", size: 64) { |bytes| Digest::SHA512.digest(bytes) }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XitDB
4
+ module Core
5
+ # An indexed list backed by a counted B-tree.
6
+ class LinkedArrayList
7
+ include Enumerable
8
+
9
+ attr_reader :cursor
10
+
11
+ def initialize(cursor)
12
+ @cursor = cursor
13
+ @tree = BTree.new(cursor)
14
+ end
15
+
16
+ def size
17
+ @tree.size
18
+ end
19
+
20
+ def get(index)
21
+ @tree.at(index)
22
+ end
23
+
24
+ def append(slot)
25
+ @tree.insert(size, slot)
26
+ self
27
+ end
28
+
29
+ def put(index, slot)
30
+ replace_child(index, slot)
31
+ end
32
+
33
+ def delete_at(index)
34
+ @tree.delete_at(index)
35
+ end
36
+
37
+ def slice(start, length)
38
+ self.class.new(@tree.slice(start, length).cursor)
39
+ end
40
+
41
+ def split(index)
42
+ @tree.split(index).map { |tree| self.class.new(tree.cursor) }
43
+ end
44
+
45
+ def concat(other)
46
+ raise TypeError, "expected LinkedArrayList" unless other.is_a?(LinkedArrayList)
47
+
48
+ self.class.new(@tree.concat(BTree.new(other.cursor)).cursor)
49
+ end
50
+
51
+ def replace_child(index, slot, **)
52
+ @tree.replace(index, slot, **)
53
+ self
54
+ end
55
+
56
+ def cursor_at(index, create: false)
57
+ slot = get(index)
58
+ if !slot && create
59
+ raise IndexError, "array index outside list" unless index == size
60
+
61
+ append(Slot.new)
62
+ slot = get(index)
63
+ end
64
+ return nil unless slot
65
+
66
+ index += size if index.negative?
67
+ cursor.array_child(index, slot)
68
+ end
69
+
70
+ def each(&block)
71
+ return enum_for(:each) unless block
72
+
73
+ @tree.each_from_index(0, &block)
74
+ self
75
+ end
76
+
77
+ def each_from_index(index, &block)
78
+ return enum_for(:each_from_index, index) unless block
79
+
80
+ @tree.each_from_index(index, &block)
81
+ self
82
+ end
83
+ end
84
+ end
85
+ end