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,407 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../input_guard"
4
+
5
+ module XitDB
6
+ module Profiles
7
+ # Maps portable values to native xitdb storage layouts.
8
+ class Portable
9
+ KINDS = {
10
+ hash_map: :map, counted_hash_map: :map, sorted_map: :sorted_map,
11
+ hash_set: :set, counted_hash_set: :set, sorted_set: :sorted_set,
12
+ array_list: :array, linked_array_list: :linked_array
13
+ }.freeze
14
+
15
+ attr_reader :hasher
16
+
17
+ def initialize(hasher: Core::Hasher.sha1)
18
+ raise TypeError, "hasher must be a Core::Hasher" unless hasher.is_a?(Core::Hasher)
19
+
20
+ @hasher = hasher
21
+ freeze
22
+ end
23
+
24
+ def encode_scalar(value, core:)
25
+ InputGuard.check!(value)
26
+ case value
27
+ when nil then Core::Slot.null
28
+ when UInt then Core::Slot.uint(value.value)
29
+ when Integer
30
+ value > (1 << 63) - 1 ? Core::Slot.uint(value) : Core::Slot.int(value)
31
+ when Float then Core::Slot.float(value)
32
+ when String then core.bytes(value)
33
+ when Core::ByteValue then core.bytes(value.bytes, format_tag: value.format_tag)
34
+ else
35
+ raise UnsupportedValueError, "unsupported portable scalar: #{Object.instance_method(:class).bind_call(value)}"
36
+ end
37
+ end
38
+
39
+ def decode_scalar(slot, core:, bound: nil, immutable: true)
40
+ case slot.tag
41
+ when :none then nil
42
+ when :uint then slot.value
43
+ when :int then slot.value >= (1 << 63) ? slot.value - (1 << 64) : slot.value
44
+ when :float then [slot.value].pack("Q>").unpack1("G")
45
+ when :bytes, :short_bytes
46
+ value = core.read_bytes(slot, bound: bound)
47
+ return value if value.format_tag
48
+
49
+ bytes = value.bytes.dup.force_encoding(Encoding::UTF_8)
50
+ bytes.force_encoding(Encoding::BINARY) unless bytes.valid_encoding?
51
+ immutable ? bytes.freeze : bytes
52
+ else
53
+ raise UnsupportedValueError, "unsupported portable scalar tag: #{slot.tag}"
54
+ end
55
+ end
56
+
57
+ def key_bytes(value)
58
+ InputGuard.check!(value)
59
+ case value
60
+ when String then value.b.freeze
61
+ else raise UnsupportedValueError, "portable keys must be strings"
62
+ end
63
+ end
64
+
65
+ def encode(value, context:, path:)
66
+ ValueWalker.encode(value, context: context, path: path)
67
+ end
68
+
69
+ def decode(slot, context:, path:, immutable: true)
70
+ if (kind = KINDS[slot.tag])
71
+ context.view(slot, path: path, kind: kind)
72
+ else
73
+ decode_scalar(slot, core: context.database.core, bound: context.read_bound, immutable: immutable)
74
+ end
75
+ end
76
+
77
+ def container(slot, context:, path:)
78
+ return nil unless KINDS.key?(slot.tag) || (slot.empty? && path.empty?)
79
+
80
+ Adapter.new(self, context.cursor(slot), context: context, path: path)
81
+ end
82
+
83
+ def empty_container(kind, context:, path:)
84
+ kind = kind.first if kind.is_a?(Array)
85
+ Adapter.new(self, context.cursor(Core::Slot.new), context: context, path: path, tag: kind)
86
+ end
87
+
88
+ def conversion_kind(adapter)
89
+ adapter.kind
90
+ end
91
+
92
+ def conversion_container(kind, context:, path:)
93
+ kind = kind.first if kind.is_a?(Array)
94
+ tag = { map: :hash_map, set: :hash_set, array: :array_list, linked_array: :linked_array_list }.fetch(kind, kind)
95
+ empty_container(tag, context: context, path: path)
96
+ end
97
+
98
+ def conversion_bytes(slot, context:)
99
+ return unless %i[bytes short_bytes].include?(slot.tag)
100
+
101
+ source = Conversion::ByteSource.new(context, slot)
102
+ [source, source.format_tag ? nil : :portable]
103
+ end
104
+
105
+ def copy_conversion_bytes(source, encoding, context:, path:)
106
+ source.write_to(context, format_tag: encoding ? nil : source.format_tag)
107
+ rescue ValueError => e
108
+ raise e.class.new(e.message, path: path), cause: e
109
+ end
110
+
111
+ def input_kind(value, path:)
112
+ InputGuard.check!(value, path: path)
113
+ klass = Object.instance_method(:class).bind_call(value)
114
+ case value
115
+ when Hash, Array, Set
116
+ validate_container(value, klass, path)
117
+ { Hash => :hash_map, Array => :array_list, Set => :hash_set }.fetch(klass)
118
+ when CollectionDeclaration
119
+ if value.key_kind
120
+ raise UnsupportedValueError.new("typed sorted declarations require the Ruby profile", path: path)
121
+ end
122
+
123
+ value.kind
124
+ end
125
+ end
126
+
127
+ def hash_defaults?(value)
128
+ default = !BasicObject.instance_method(:equal?).bind_call(value.default, nil)
129
+ !value.default_proc.nil? || default || value.compare_by_identity?
130
+ end
131
+
132
+ def validate_container(value, klass, path)
133
+ unless [Hash, Array, Set].include?(klass)
134
+ raise UnsupportedValueError.new("container subclasses and singleton behavior are unsupported", path: path)
135
+ end
136
+ if value.is_a?(Hash) && hash_defaults?(value)
137
+ raise UnsupportedValueError.new("Hash defaults and identity comparison are unsupported", path: path)
138
+ end
139
+ return unless value.is_a?(Set) && value.compare_by_identity?
140
+
141
+ raise UnsupportedValueError.new("Set identity comparison is unsupported", path: path)
142
+ end
143
+
144
+ def input_entries(value, path:)
145
+ Enumerator.new do |output|
146
+ # @type var seen: Hash[String, bool]
147
+ seen = {}
148
+ case value
149
+ when Hash, Set
150
+ entries = value.is_a?(Hash) ? value.each_pair : value.each.lazy.map { |member| [member, member] }
151
+ entries.each do |key, child|
152
+ begin
153
+ bytes = key_bytes(key)
154
+ rescue ValueError => e
155
+ raise e.class.new(e.message, path: path + [key]), cause: e
156
+ end
157
+ if seen.key?(bytes)
158
+ raise KeyCollisionError.new("distinct input keys have equal portable bytes", path: path + [key])
159
+ end
160
+
161
+ seen[bytes] = true
162
+ output << [key, child]
163
+ end
164
+ when Array
165
+ value.each_with_index { |child, index| output << [index, child] }
166
+ end
167
+ end
168
+ end
169
+
170
+ # All knowledge of portable storage layouts stays inside this adapter.
171
+ class Adapter
172
+ attr_reader :kind, :cursor
173
+
174
+ def initialize(profile, cursor, context:, path:, tag: nil)
175
+ @profile = profile
176
+ @cursor = cursor
177
+ @context = context
178
+ @path = path
179
+ tag ||= cursor.slot.tag == :none ? :hash_map : cursor.slot.tag
180
+ @kind = KINDS.fetch(tag)
181
+ @collection = case tag
182
+ when :hash_map, :hash_set, :counted_hash_map, :counted_hash_set
183
+ Core::HashMap.new(cursor, counted: %i[counted_hash_map counted_hash_set].include?(tag),
184
+ set: %i[hash_set counted_hash_set].include?(tag))
185
+ when :array_list then Core::ArrayList.new(cursor)
186
+ when :linked_array_list then Core::LinkedArrayList.new(cursor)
187
+ when :sorted_map, :sorted_set then Core::SortedMap.new(cursor, set: tag == :sorted_set)
188
+ end
189
+ end
190
+
191
+ def slot
192
+ cursor.slot
193
+ end
194
+
195
+ def size
196
+ @collection.size
197
+ end
198
+
199
+ def empty?
200
+ size.zero?
201
+ end
202
+
203
+ def lookup(selector)
204
+ collection = @collection
205
+ if collection.is_a?(Core::ArrayList) || collection.is_a?(Core::LinkedArrayList)
206
+ collection.get(index_selector(selector))
207
+ elsif collection.is_a?(Core::SortedMap)
208
+ collection.get(key_bytes(selector))
209
+ else
210
+ collection.get(@profile.hasher.digest(key_bytes(selector)))
211
+ end
212
+ end
213
+
214
+ def assign(selector, stored)
215
+ case kind
216
+ when :array, :linked_array
217
+ if selector == size
218
+ sequence_collection.append(stored)
219
+ else
220
+ sequence_collection.put(index_selector(selector),
221
+ stored)
222
+ end
223
+ when :sorted_map
224
+ sorted_collection.put(key_bytes(selector), value: stored)
225
+ when :sorted_set
226
+ sorted_collection.add(key_bytes(selector))
227
+ when :map, :set
228
+ bytes = key_bytes(selector)
229
+ digest = @profile.hasher.digest(bytes)
230
+ key = @context.database.core.bytes(bytes)
231
+ kind == :map ? hash_collection.put(digest, key: key, value: stored) : hash_collection.add(digest, key: key)
232
+ end
233
+ self
234
+ end
235
+
236
+ def delete(selector)
237
+ case kind
238
+ when :array
239
+ index = selector
240
+ count = size
241
+ raise IndexError, "array index outside list" unless index.is_a?(Integer) && index >= 0 && index < count
242
+
243
+ (index...(count - 1)).each do |position|
244
+ array_collection.put(position, required_slot(array_collection.get(position + 1)))
245
+ end
246
+ array_collection.truncate(count - 1)
247
+ when :linked_array then linked_collection.delete_at(index_selector(selector))
248
+ when :map, :set then hash_collection.remove(@profile.hasher.digest(key_bytes(selector)))
249
+ else sorted_collection.remove(key_bytes(selector))
250
+ end
251
+ end
252
+
253
+ # Radix insertion moves the suffix and performs one radix write per slot.
254
+ # Linked insertion follows one count-guided tree path.
255
+ def insert(index, stored)
256
+ if kind == :linked_array
257
+ Core::BTree.new(cursor).insert(index, stored)
258
+ else
259
+ count = size
260
+ array_collection.append(stored)
261
+ (count - 1).downto(index) do |position|
262
+ array_collection.put(position + 1, required_slot(array_collection.get(position)))
263
+ end
264
+ array_collection.put(index, stored)
265
+ end
266
+ self
267
+ end
268
+
269
+ def truncate(count)
270
+ if kind == :linked_array
271
+ cursor.replace(linked_collection.slice(0, count).cursor.slot)
272
+ else
273
+ array_collection.truncate(count)
274
+ end
275
+ self
276
+ end
277
+
278
+ def slice(start, count)
279
+ return linked_collection.slice(start, count).cursor.slot if kind == :linked_array
280
+
281
+ output = @profile.empty_container(:array_list, context: @context, path: @path)
282
+ count.times { |index| output.assign(index, required_slot(lookup(start + index))) }
283
+ output.slot
284
+ end
285
+
286
+ def concat(other)
287
+ if kind == :linked_array && other.kind == :linked_array
288
+ cursor.replace(linked_collection.concat(Core::LinkedArrayList.new(other.cursor)).cursor.slot)
289
+ else
290
+ other.each_entry { |_index, stored| assign(size, stored) }
291
+ end
292
+ self
293
+ end
294
+
295
+ def rank(key)
296
+ sorted_collection.rank(@profile.key_bytes(key))
297
+ end
298
+
299
+ def each_from(key)
300
+ return enum_for(:each_from, key) unless block_given?
301
+
302
+ sorted_collection.each_from(@profile.key_bytes(key)) { |bytes, stored| yield decode_key(bytes), stored }
303
+ self
304
+ end
305
+
306
+ def each_from_index(index)
307
+ return enum_for(:each_from_index, index) unless block_given?
308
+
309
+ index += size if index.negative?
310
+ return self unless index >= 0 && index < size
311
+
312
+ case kind
313
+ when :array
314
+ (index...size).each { |position| yield position, required_slot(lookup(position)) }
315
+ when :linked_array
316
+ linked_collection.each_from_index(index).with_index(index) { |stored, position| yield position, stored }
317
+ else
318
+ sorted_collection.each_from_index(index) { |bytes, stored| yield decode_key(bytes), stored }
319
+ end
320
+ self
321
+ end
322
+
323
+ def each_entry
324
+ return enum_for(:each_entry) unless block_given?
325
+
326
+ case kind
327
+ when :array, :linked_array
328
+ sequence_collection.each.with_index { |stored, index| yield index, stored }
329
+ when :sorted_map, :sorted_set
330
+ sorted_collection.each { |bytes, stored| yield decode_key(bytes), stored }
331
+ else
332
+ hash_collection.each_entry do |_digest, key, stored|
333
+ value = @profile.decode_scalar(key, core: @context.database.core, bound: @context.read_bound)
334
+ raise CorruptionError, "portable map key is not an untagged string" unless value.is_a?(String)
335
+
336
+ yield value, stored
337
+ end
338
+ end
339
+ self
340
+ end
341
+
342
+ private
343
+
344
+ def index_selector(selector)
345
+ raise TypeError, "index must be an Integer" unless selector.is_a?(Integer)
346
+
347
+ selector
348
+ end
349
+
350
+ def required_slot(slot)
351
+ raise CorruptionError, "missing collection slot" unless slot
352
+
353
+ slot
354
+ end
355
+
356
+ def array_collection
357
+ collection = @collection
358
+ raise CorruptionError, "unexpected collection layout" unless collection.is_a?(Core::ArrayList)
359
+
360
+ collection
361
+ end
362
+
363
+ def linked_collection
364
+ collection = @collection
365
+ raise CorruptionError, "unexpected collection layout" unless collection.is_a?(Core::LinkedArrayList)
366
+
367
+ collection
368
+ end
369
+
370
+ def sorted_collection
371
+ collection = @collection
372
+ raise CorruptionError, "unexpected collection layout" unless collection.is_a?(Core::SortedMap)
373
+
374
+ collection
375
+ end
376
+
377
+ def hash_collection
378
+ collection = @collection
379
+ raise CorruptionError, "unexpected collection layout" unless collection.is_a?(Core::HashMap)
380
+
381
+ collection
382
+ end
383
+
384
+ def sequence_collection
385
+ collection = @collection
386
+ unless collection.is_a?(Core::ArrayList) || collection.is_a?(Core::LinkedArrayList)
387
+ raise CorruptionError, "unexpected sequence layout"
388
+ end
389
+
390
+ collection
391
+ end
392
+
393
+ def key_bytes(selector)
394
+ @profile.key_bytes(selector)
395
+ rescue ValueError => e
396
+ raise e.class.new(e.message, path: @path + [selector]), cause: e
397
+ end
398
+
399
+ def decode_key(bytes)
400
+ value = bytes.dup.force_encoding(Encoding::UTF_8)
401
+ value.force_encoding(Encoding::BINARY) unless value.valid_encoding?
402
+ value.freeze
403
+ end
404
+ end
405
+ end
406
+ end
407
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ruby/codec"
4
+
5
+ module XitDB
6
+ module Profiles
7
+ # Preserves Ruby scalar types and collection ordering.
8
+ class Ruby
9
+ attr_reader :hasher, :codecs
10
+
11
+ def initialize(codecs: [])
12
+ @registry = XitDB::Ruby::CodecRegistry.new(codecs)
13
+ @codecs = @registry.codecs
14
+ @hasher = Core::Hasher.new(id: "rb01", size: 32) { |bytes| Digest::SHA256.digest(bytes) }
15
+ freeze
16
+ end
17
+
18
+ def encode(value, context:, path:)
19
+ ValueWalker.encode(value, context: context, path: path)
20
+ end
21
+
22
+ def encode_scalar(value, core:)
23
+ XitDB::Ruby::Scalars.encode(value, core: core, registry: @registry)
24
+ end
25
+
26
+ def decode(slot, context:, path:, immutable: true)
27
+ if (adapter = container(slot, context: context, path: path))
28
+ context.view(slot, path: path, kind: adapter.kind)
29
+ else
30
+ XitDB::Ruby::Scalars.decode(slot, core: context.database.core, bound: context.read_bound,
31
+ registry: @registry, immutable: immutable)
32
+ end
33
+ end
34
+
35
+ def key_bytes(value)
36
+ XitDB::Ruby::KeyEncoding.dump(value)
37
+ end
38
+
39
+ def container(slot, context:, path:)
40
+ return nil unless slot.tag == :array_list || (slot.empty? && path.empty?)
41
+
42
+ XitDB::Ruby::Containers.new(self, context.cursor(slot), context: context, path: path)
43
+ end
44
+
45
+ def empty_container(kind, context:, path:)
46
+ kind, key_kind = kind if kind.is_a?(::Array)
47
+ XitDB::Ruby::Containers.new(self, context.cursor(Core::Slot.new), context: context, path: path,
48
+ kind: kind, key_kind: key_kind)
49
+ end
50
+
51
+ def conversion_kind(adapter)
52
+ return adapter.kind unless %i[sorted_map sorted_set].include?(adapter.kind)
53
+
54
+ stored = Core::ArrayList.new(adapter.cursor).get(1)
55
+ raise InvalidValueError, "missing sorted key kind" unless stored
56
+
57
+ key = stored.value
58
+ [adapter.kind, XitDB::Ruby::Containers::KEY_KINDS.key(key)]
59
+ end
60
+
61
+ def conversion_container(kind, context:, path:)
62
+ # @type var kind: Symbol | [Symbol, Symbol?]
63
+ kind = [kind, :string] if kind.is_a?(Symbol) && %i[sorted_map sorted_set].include?(kind)
64
+ empty_container(kind, context: context, path: path)
65
+ end
66
+
67
+ def conversion_bytes(slot, context:)
68
+ return unless %i[bytes short_bytes].include?(slot.tag)
69
+
70
+ source = Conversion::ByteSource.new(context, slot)
71
+ return [source, :binary] unless source.format_tag
72
+ return [source, nil] unless source.format_tag == "rb"
73
+ return unless source.read(0, [source.size, 2].min) == "\x01\x01".b
74
+
75
+ length, position = source.unsigned(2)
76
+ raise UnsupportedEncodingError, "encoding name is too long" if length > Core::Bytes::CHUNK_SIZE
77
+
78
+ name = source.read(position, length).force_encoding(Encoding::UTF_8)
79
+ raise InvalidValueError, "invalid encoding name" unless name.valid_encoding?
80
+
81
+ begin
82
+ encoding = Encoding.find(name)
83
+ rescue ArgumentError => e
84
+ raise UnsupportedEncodingError, e.message
85
+ end
86
+ size, position = source.unsigned(position + length)
87
+ raise InvalidValueError, "trailing or truncated String bytes" unless position + size == source.size
88
+
89
+ [source.slice(position, size), encoding]
90
+ end
91
+
92
+ def copy_conversion_bytes(source, encoding, context:, path:)
93
+ if source.format_tag == "rb"
94
+ raise UnsupportedValueError.new("portable rb bytes require an explicit transform", path: path)
95
+ end
96
+ return source.write_to(context) if !encoding || encoding == :binary
97
+
98
+ encoding = source.portable_encoding if encoding == :portable
99
+ prefix = "\x01\x01".b + XitDB::Ruby::Wire.t(encoding.name) + XitDB::Ruby::Wire.u(source.size)
100
+ source.write_to(context, prefix: prefix, format_tag: "rb")
101
+ end
102
+
103
+ def input_kind(value, path:)
104
+ InputGuard.check!(value, path: path)
105
+ return nil if @registry.for_value(value)
106
+
107
+ klass = Object.instance_method(:class).bind_call(value)
108
+ case value
109
+ when Hash, ::Array, ::Set
110
+ validate_container(value, klass, path)
111
+ { Hash => :map, ::Array => :array, ::Set => :set }.fetch(klass)
112
+ when CollectionDeclaration
113
+ declaration_kind(value, klass, path)
114
+ end
115
+ end
116
+
117
+ def hash_defaults?(value)
118
+ default = !BasicObject.instance_method(:equal?).bind_call(value.default, nil)
119
+ !value.default_proc.nil? || default || value.compare_by_identity?
120
+ end
121
+
122
+ def validate_container(value, klass, path)
123
+ unless [Hash, ::Array, ::Set].include?(klass)
124
+ raise UnsupportedValueError.new("container subclasses are unsupported", path: path)
125
+ end
126
+ if value.is_a?(Hash) && hash_defaults?(value)
127
+ raise UnsupportedValueError.new("Hash defaults and identity comparison are unsupported", path: path)
128
+ end
129
+ return unless value.is_a?(::Set) && value.compare_by_identity?
130
+
131
+ raise UnsupportedValueError.new("Set identity comparison is unsupported", path: path)
132
+ end
133
+
134
+ def declaration_kind(value, klass, path)
135
+ unless CollectionDeclaration.equal?(klass)
136
+ raise UnsupportedValueError.new("collection declaration subclasses are unsupported", path: path)
137
+ end
138
+
139
+ if %i[sorted_map sorted_set].include?(value.kind)
140
+ unless value.key_kind
141
+ raise UnsupportedValueError.new("Ruby sorted collections require an explicit key: kind", path: path)
142
+ end
143
+
144
+ return [value.kind, value.key_kind]
145
+ end
146
+ kinds = { hash_map: :map, counted_hash_map: :map, hash_set: :set, counted_hash_set: :set,
147
+ array_list: :array, linked_array_list: :linked_array }
148
+ kinds.fetch(value.kind) do
149
+ raise UnsupportedValueError.new("unsupported Ruby collection declaration", path: path)
150
+ end
151
+ end
152
+
153
+ def input_entries(value, path:)
154
+ Enumerator.new do |output|
155
+ case value
156
+ when Hash, ::Set
157
+ entries = value.is_a?(Hash) ? value.each_pair : value.each.lazy.map { |member| [member, nil] }
158
+ entries.each do |key, child|
159
+ begin
160
+ key_bytes(key)
161
+ rescue ValueError => e
162
+ raise e.class.new(e.message, path: path + [key]), cause: e
163
+ end
164
+ output << [key, child]
165
+ end
166
+ when ::Array
167
+ value.each_with_index { |child, index| output << [index, child] }
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XitDB
4
+ # Identifies a history entry and verifies its file generation and trailer.
5
+ class Revision
6
+ def initialize(file_id:, trailer_position:, checksum:, index:)
7
+ @file_id = file_id
8
+ @trailer_position = trailer_position
9
+ @checksum = checksum
10
+ @index = index
11
+ freeze
12
+ end
13
+
14
+ def resolve(database)
15
+ core = database.core
16
+ current = core.root_cursor
17
+ unless @file_id == core.header.file_id && @trailer_position + Core::Database::TRAILER_SIZE <= current.bound
18
+ raise ForeignRevisionError, "revision does not belong to this file generation"
19
+ end
20
+
21
+ trailer = core.read_at(@trailer_position, Core::Database::TRAILER_SIZE, bound: current.bound)
22
+ checksum_bytes = trailer.byteslice(17, 8) || raise(CorruptionError, "truncated revision trailer")
23
+ checksum = checksum_bytes.unpack1("Q>")
24
+ payload = trailer.byteslice(0, 17) || raise(CorruptionError, "truncated revision trailer")
25
+ expected = Core::CRC64.call([@file_id, @trailer_position].pack("Q>Q>") + payload)
26
+ unless checksum == @checksum && checksum == expected
27
+ raise ForeignRevisionError, "revision trailer proof is no longer valid"
28
+ end
29
+
30
+ slot_bytes = trailer.byteslice(0, 9) || raise(CorruptionError, "truncated revision trailer")
31
+ cursor = Core::Cursor.new(database: core, slot: Core::Slot.parse(slot_bytes),
32
+ bound: @trailer_position + Core::Database::TRAILER_SIZE)
33
+ database.snapshot_from_history(cursor, @index, revision: self)
34
+ rescue CorruptionError, IndexError, InvalidValueError
35
+ raise ForeignRevisionError, "revision history is no longer valid"
36
+ end
37
+
38
+ def inspect
39
+ "#<XitDB::Revision>"
40
+ end
41
+ end
42
+ end