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,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XitDB
4
+ # Owns one captured root or one transaction's logical root.
5
+ class Context
6
+ Location = Struct.new(:path, :membership, :expired)
7
+ private_constant :Location
8
+
9
+ attr_reader :database, :profile, :transaction, :root, :reference_proof
10
+
11
+ def initialize(database:, root:, bound:, transaction: nil, readonly: false, reference_proof: nil)
12
+ @database = database
13
+ @profile = database.profile
14
+ @root = root
15
+ @bound = bound
16
+ @transaction = transaction
17
+ @readonly = readonly
18
+ @changed = false
19
+ @locations = ObjectSpace::WeakMap.new if transaction && !readonly
20
+ return if transaction
21
+
22
+ @reference_proof = reference_proof || database.core.read_at(
23
+ bound - Core::Database::TRAILER_SIZE, Core::Database::TRAILER_SIZE, bound: bound
24
+ ).freeze
25
+ end
26
+
27
+ def check!
28
+ database.core.check_open!
29
+ transaction&.check_active!
30
+ end
31
+
32
+ def read
33
+ database.core.with_operation do
34
+ check!
35
+ yield
36
+ end
37
+ end
38
+
39
+ def writable!
40
+ check!
41
+ raise ReadOnlyError, "view is read-only" if @readonly || !transaction
42
+
43
+ transaction.check_logical_mutation!
44
+ end
45
+
46
+ def writable_transaction
47
+ check!
48
+ raise ReadOnlyError, "view is read-only" if @readonly
49
+
50
+ transaction || raise(ReadOnlyError, "view is read-only")
51
+ end
52
+
53
+ def collection(slot, path:)
54
+ profile.container(slot, context: self, path: path) || raise(ExpiredViewError, "view location is not a collection")
55
+ end
56
+
57
+ def read_bound
58
+ check!
59
+ transaction ? transaction.length : @bound
60
+ end
61
+
62
+ def reference_compatible?(destination)
63
+ check!
64
+ destination.check!
65
+ target = destination.database.core
66
+ return false unless database.core.storage_identity == target.storage_identity
67
+ unless profile.instance_of?(destination.profile.class) && database.core.header.to_bytes == target.header.to_bytes
68
+ return false
69
+ end
70
+ return transaction.equal?(destination.transaction) if transaction
71
+ return false if @bound > destination.read_bound
72
+
73
+ reference_proof == target.read_at(@bound - Core::Database::TRAILER_SIZE,
74
+ Core::Database::TRAILER_SIZE, bound: destination.read_bound)
75
+ rescue CorruptionError
76
+ false
77
+ end
78
+
79
+ def cursor(slot)
80
+ check!
81
+ if transaction
82
+ transaction.cursor(slot: slot)
83
+ else
84
+ Core::Cursor.new(database: database.core, slot: slot, bound: read_bound)
85
+ end
86
+ end
87
+
88
+ def resolve(path)
89
+ check!
90
+ slot = root
91
+ path.each_with_index do |selector, index|
92
+ slot = collection(slot, path: path.first(index)).lookup(selector)
93
+ raise ExpiredViewError, "view location no longer exists" unless slot
94
+ end
95
+ slot
96
+ end
97
+
98
+ def view(_slot, path:, kind:)
99
+ klass = { map: Views::Map, sorted_map: Views::SortedMap, array: Views::Array,
100
+ linked_array: Views::LinkedArray, set: Views::Set, sorted_set: Views::SortedSet }.fetch(kind)
101
+ klass.new(context: self, path: path, kind: kind)
102
+ end
103
+
104
+ def changed?
105
+ @changed
106
+ end
107
+
108
+ def copy_from(view, at:, transform: nil)
109
+ writable!
110
+ raise TypeError, "copy source must be a view" unless Object.instance_method(:is_a?).bind_call(view, Views::Base)
111
+ raise TypeError, "copy path must be an Array" unless at.instance_of?(Array)
112
+ raise TypeError, "transform must respond to call" unless transform.nil? || transform.respond_to?(:call)
113
+
114
+ read do
115
+ view.context.read do
116
+ source, source_adapter = capture_copy_source(view, at)
117
+ location = copy_location(at)
118
+ parent = location.take([location.size - 1, 0].max)
119
+ present = !location.empty? && !collection(resolve(parent), path: parent).lookup(location.last).nil?
120
+ perform_copy(source, source_adapter, location, parent, transform)
121
+ refresh_copy_location(location, parent, present)
122
+ profile.decode(resolve(location), context: self, path: location)
123
+ end
124
+ end
125
+ end
126
+
127
+ # Publish the logical root only after the complete operation succeeds.
128
+ def mutate(path, targets: nil, positions: nil)
129
+ writable!
130
+ transaction = writable_transaction
131
+ transaction.with_logical_mutation do
132
+ check_stream_mutation!(path, targets: targets, positions: positions)
133
+ checkpoint = transaction.checkpoint
134
+ completed = false
135
+ begin
136
+ # @type var ancestors: ::Array[[adapter, selector]]
137
+ ancestors = []
138
+ slot = root
139
+ path.each_with_index do |selector, index|
140
+ adapter = collection(slot, path: path.first(index))
141
+ ancestors << [adapter, selector]
142
+ slot = adapter.lookup(selector)
143
+ raise ExpiredViewError, "view location no longer exists" unless slot
144
+ end
145
+ target = cursor(slot)
146
+ result = yield target
147
+ if target.slot == slot
148
+ completed = true
149
+ return result
150
+ end
151
+ slot = target.slot
152
+ ancestors.reverse_each do |adapter, selector|
153
+ adapter.assign(selector, slot)
154
+ slot = adapter.slot
155
+ end
156
+ @root = slot
157
+ @changed = true
158
+ completed = true
159
+ result
160
+ ensure
161
+ completed ? transaction.release_checkpoint(checkpoint) : transaction.restore(checkpoint)
162
+ end
163
+ end
164
+ end
165
+
166
+ def register_stream(path, write:)
167
+ check!
168
+ key = path_key(path)
169
+ # @type var streams: ::Array[registration]
170
+ streams = @streams ||= []
171
+ if streams.any? { |other, writing| (write || writing) && overlapping_paths?(key, other) }
172
+ raise ConcurrentModificationError, "value has an active byte stream"
173
+ end
174
+
175
+ # @type var registration: registration
176
+ registration = [key, write]
177
+ streams << registration
178
+ registration
179
+ end
180
+
181
+ def release_stream(registration)
182
+ @streams&.delete_if { |item| item.equal?(registration) }
183
+ end
184
+
185
+ def observe(path)
186
+ check!
187
+ locations = @locations
188
+ return unless locations
189
+
190
+ location = Location.new(path_key(path), 0, false)
191
+ locations[location] = true
192
+ location
193
+ end
194
+
195
+ def replace_location(path)
196
+ each_location(path) { |location| location.expired = true }
197
+ end
198
+
199
+ def invalidate_positions(path, start)
200
+ each_location(path) do |location|
201
+ index = location.path[path.size]
202
+ location.expired = true if index.is_a?(Integer) && index >= start
203
+ end
204
+ end
205
+
206
+ def change_membership(path)
207
+ each_location(path) { |location| location.membership += 1 if location.path.size == path.size }
208
+ end
209
+
210
+ def invalidate_children(path)
211
+ each_location(path) { |location| location.expired = true if location.path.size > path.size }
212
+ end
213
+
214
+ def check_stream_mutation!(path, targets: nil, positions: nil)
215
+ streams = @streams
216
+ return if !streams || streams.empty?
217
+
218
+ keys = mutation_keys(path, targets, positions)
219
+ parent = path_key(path) if positions
220
+ conflict = streams.any? do |active, _writing|
221
+ keys.any? { |key| overlapping_paths?(key, active) } ||
222
+ position_conflict?(parent, active, positions)
223
+ end
224
+ raise ConcurrentModificationError, "value has an active byte stream" if conflict
225
+ end
226
+
227
+ private
228
+
229
+ def mutation_keys(path, targets, positions)
230
+ (targets || (positions ? [] : [path])).map { |target| path_key(target) }
231
+ end
232
+
233
+ def capture_copy_source(view, at)
234
+ view.check!
235
+ source_slot = view.slot
236
+ view.context.transaction&.freeze
237
+ source_adapter = view.adapter
238
+ if at.empty? && !%i[map sorted_map].include?(source_adapter.kind)
239
+ raise ProfileMismatchError, "context root must be a map"
240
+ end
241
+
242
+ source = Context.new(database: view.context.database, root: source_slot, bound: view.context.read_bound,
243
+ transaction: view.context.transaction, readonly: true,
244
+ reference_proof: view.context.reference_proof)
245
+ [source, source_adapter]
246
+ end
247
+
248
+ def copy_location(at)
249
+ # @type var location: path
250
+ location = []
251
+ set_kinds = %i[set sorted_set]
252
+ array_kinds = %i[array linked_array]
253
+ at.each_with_index do |selector, index|
254
+ adapter = profile.container(resolve(location), context: self, path: location)
255
+ raise TypeError, "copy parent must be a collection" unless adapter
256
+ raise TypeError, "cannot assign a collection to a set member" if set_kinds.include?(adapter.kind)
257
+
258
+ selector = copy_array_position(selector, adapter.size) if array_kinds.include?(adapter.kind)
259
+ adapter.lookup(selector)
260
+ location << case selector
261
+ when Time then selector.dup.freeze
262
+ when String then String.new(selector).freeze
263
+ else selector
264
+ end
265
+ resolve(location) if index < at.size - 1
266
+ end
267
+ location
268
+ end
269
+
270
+ def copy_array_position(selector, size)
271
+ selector = Integer.try_convert(selector) || raise(TypeError, "array position must be an Integer")
272
+ long_max = (1 << (([0].pack("l!").bytesize * 8) - 1)) - 1
273
+ raise RangeError, "array position is too large" unless ((-long_max - 1)..long_max).cover?(selector)
274
+
275
+ selector += size if selector.negative?
276
+ raise IndexError, "array index outside list" if selector.negative?
277
+ raise IndexError, "array size too big" if selector + 1 > long_max / 0.size
278
+
279
+ selector
280
+ end
281
+
282
+ def perform_copy(source, source_adapter, location, parent, transform)
283
+ operation = lambda do
284
+ mutate(location.empty? ? [] : parent, targets: [location]) do |cursor|
285
+ assign_copy(cursor, source, source_adapter, location, parent, transform)
286
+ end
287
+ end
288
+ source_transaction = source.transaction
289
+ if source_transaction && !source_transaction.equal?(transaction)
290
+ source_transaction.with_logical_mutation(&operation)
291
+ else
292
+ operation.call
293
+ end
294
+ end
295
+
296
+ def assign_copy(cursor, source, source_adapter, location, parent, transform)
297
+ copied = Conversion.new(source, self, transform: transform).copy(source.root, adapter: source_adapter)
298
+ if location.empty?
299
+ cursor.replace(copied)
300
+ else
301
+ adapter = collection(cursor.slot, path: parent)
302
+ if %i[array linked_array].include?(adapter.kind)
303
+ position = Integer.try_convert(location.last) || raise(TypeError, "array position must be an Integer")
304
+ adapter.assign(adapter.size, Core::Slot.null) while adapter.size < position
305
+ end
306
+ adapter.assign(location.last, copied)
307
+ cursor.replace(adapter.slot)
308
+ end
309
+ end
310
+
311
+ def refresh_copy_location(location, parent, present)
312
+ if location.empty?
313
+ invalidate_children([])
314
+ change_membership([])
315
+ else
316
+ replace_location(location)
317
+ change_membership(parent) unless present
318
+ end
319
+ end
320
+
321
+ def position_conflict?(parent, active, positions)
322
+ return false unless parent && positions && overlapping_paths?(parent, active)
323
+
324
+ active.size <= parent.size || positions.cover?(active[parent.size])
325
+ end
326
+
327
+ def overlapping_paths?(left, right)
328
+ left.first(right.size).eql?(right) || right.first(left.size).eql?(left)
329
+ end
330
+
331
+ def each_location(path)
332
+ key = path_key(path)
333
+ retained = ObjectSpace::WeakMap.new
334
+ @locations&.each_key do |location|
335
+ next if location.expired
336
+
337
+ yield location if location.path.first(key.size).eql?(key)
338
+ retained[location] = true unless location.expired
339
+ end
340
+ @locations = retained
341
+ end
342
+
343
+ def path_key(path)
344
+ slot = root
345
+ path.each_with_index.map do |selector, index|
346
+ adapter = collection(slot, path: path.first(index))
347
+ key = if defined?(Ruby::Containers) && adapter.is_a?(Ruby::Containers)
348
+ adapter.selector_identity(selector)
349
+ else
350
+ case selector
351
+ when Integer then selector
352
+ else profile.key_bytes(selector)
353
+ end
354
+ end
355
+ slot = adapter.lookup(selector) if index < path.size - 1
356
+ key
357
+ end
358
+ end
359
+ end
360
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XitDB
4
+ # Copies logical entries with one iterator per ancestor and no node cache.
5
+ class Conversion
6
+ def initialize(source, destination, transform: nil)
7
+ @source = source
8
+ @destination = destination
9
+ @transform = transform
10
+ end
11
+
12
+ def copy(slot, path: [], adapter: nil)
13
+ # @type var stack: ::Array[conversion_frame]
14
+ stack = []
15
+ # @type var active: ::Hash[[Symbol, Integer], bool]
16
+ active = {}
17
+ # @type var current: [Core::Slot, path]?
18
+ current = [slot, path]
19
+ selected = adapter
20
+ # @type var result: Core::Slot?
21
+ result = nil
22
+ while true # rubocop:disable Style/InfiniteLoop -- Callback StopIteration must propagate.
23
+ if current
24
+ result = begin_value(current[0], current[1], selected, stack, active)
25
+ current = nil
26
+ selected = nil
27
+ end
28
+ if result
29
+ break if stack.empty?
30
+
31
+ assign_parent(stack, result)
32
+ end
33
+ frame = stack.last || raise(CorruptionError, "missing conversion frame")
34
+ entry = next_entry(frame)
35
+ if entry
36
+ current = select_child(frame, entry)
37
+ else
38
+ result = finish_frame(frame, stack, active)
39
+ break if stack.empty?
40
+ end
41
+ result = nil
42
+ end
43
+ result || raise(CorruptionError, "conversion produced no value")
44
+ end
45
+
46
+ private
47
+
48
+ def begin_value(stored, location, selected, stack, active)
49
+ adapter = selected || @source.profile.container(stored, context: @source, path: location)
50
+ return copy_scalar(stored, location) unless adapter
51
+
52
+ # @type var identity: [Symbol, Integer]
53
+ identity = [stored.tag, stored.value]
54
+ raise CyclicValueError.new("cyclic stored value", path: location) if active[identity]
55
+
56
+ active[identity] = true
57
+ kind = @source.profile.conversion_kind(adapter)
58
+ output = @destination.profile.conversion_container(kind, context: @destination, path: location)
59
+ stack << [identity, location, adapter.each_entry, output, nil]
60
+ nil
61
+ end
62
+
63
+ def copy_scalar(stored, location)
64
+ bytes = @source.profile.conversion_bytes(stored, context: @source) unless @transform
65
+ if bytes
66
+ return @destination.profile.copy_conversion_bytes(bytes[0], bytes[1], context: @destination, path: location)
67
+ end
68
+
69
+ value = @source.profile.decode(stored, context: @source, path: location)
70
+ value = transform(value, location, :value)
71
+ if @source.profile.instance_of?(Profiles::Portable) && !@destination.profile.instance_of?(Profiles::Portable)
72
+ case value
73
+ when Core::ByteValue
74
+ if value.format_tag == "rb"
75
+ raise UnsupportedValueError.new("portable rb bytes require an explicit transform", path: location)
76
+ end
77
+ end
78
+ end
79
+ @destination.profile.encode(value, context: @destination, path: location)
80
+ end
81
+
82
+ def assign_parent(stack, stored)
83
+ parent = stack.last || raise(CorruptionError, "missing conversion parent")
84
+ parent[3].assign(parent[4], stored)
85
+ end
86
+
87
+ def finish_frame(frame, stack, active)
88
+ stored = frame[3].slot
89
+ active.delete(frame[0])
90
+ stack.pop
91
+ assign_parent(stack, stored) unless stack.empty?
92
+ stored
93
+ end
94
+
95
+ # Callback StopIteration must abort the operation, so rescue only #next.
96
+ def next_entry(frame)
97
+ frame[2].next
98
+ rescue StopIteration
99
+ nil
100
+ end
101
+
102
+ def select_child(frame, entry)
103
+ selector, child = entry
104
+ child_path = frame[1] + [selector]
105
+ if %i[map sorted_map set sorted_set].include?(frame[3].kind)
106
+ selector = transform_key(selector, child_path)
107
+ if frame[3].lookup(selector)
108
+ raise KeyCollisionError.new("converted keys have equal destination identity", path: child_path)
109
+ end
110
+
111
+ selector = case selector
112
+ when String then String.new(selector).freeze
113
+ when Time then selector.dup.freeze
114
+ else selector
115
+ end
116
+ end
117
+ frame[4] = selector
118
+ if %i[set sorted_set].include?(frame[3].kind)
119
+ frame[3].assign(selector, Core::Slot.null)
120
+ nil
121
+ else
122
+ [child, child_path]
123
+ end
124
+ end
125
+
126
+ def transform_key(value, path)
127
+ converted = transform(value, path, :key)
128
+ case converted
129
+ when String, Integer, Float, Time, Symbol, TrueClass, FalseClass, NilClass, UInt
130
+ converted
131
+ else
132
+ raise UnsupportedValueError.new("unsupported converted key", path: path)
133
+ end
134
+ end
135
+
136
+ def transform(value, path, role)
137
+ callback = @transform
138
+ return value unless callback
139
+
140
+ immutable_path = path.map do |part|
141
+ case part
142
+ when String then String.new(part).freeze
143
+ else part
144
+ end
145
+ end.freeze
146
+ callback.call(value: value, path: immutable_path, role: role)
147
+ end
148
+
149
+ # A bounded byte range, including tagged bytes and Ruby String payloads.
150
+ class ByteSource
151
+ attr_reader :size, :format_tag
152
+
153
+ def initialize(context, slot)
154
+ @source = Core::Bytes.open(context.database.core, slot, bound: context.read_bound)
155
+ @start = 0
156
+ @size = @source.size
157
+ @format_tag = @source.format_tag
158
+ end
159
+
160
+ def read(position, length)
161
+ unless position >= 0 && length >= 0 && position + length <= size
162
+ raise InvalidValueError, "truncated byte payload"
163
+ end
164
+
165
+ @source.read(@start + position, length)
166
+ end
167
+
168
+ def unsigned(position)
169
+ value = shift = 0
170
+ while true
171
+ part = read(position, 1).getbyte(0) || raise(InvalidValueError, "truncated byte payload")
172
+ position += 1
173
+ value |= (part & 0x7f) << shift
174
+ if part < 0x80
175
+ raise InvalidValueError, "nonminimal unsigned field" if shift.positive? && part.zero?
176
+
177
+ break
178
+ end
179
+ shift += 7
180
+ raise InvalidValueError, "byte field length exceeds storage range" if shift >= 70
181
+ end
182
+ [value, position]
183
+ end
184
+
185
+ def slice(position, length)
186
+ read(position + length, 0)
187
+ output = dup
188
+ output.instance_variable_set(:@size, length)
189
+ output.instance_variable_set(:@format_tag, nil)
190
+ output.instance_variable_set(:@start, @start + position)
191
+ output
192
+ end
193
+
194
+ def portable_encoding
195
+ converter = Encoding::Converter.new(Encoding::UTF_8, Encoding::UTF_16LE)
196
+ each_chunk { |chunk| converter.convert(chunk) }
197
+ converter.finish
198
+ Encoding::UTF_8
199
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
200
+ Encoding::BINARY
201
+ end
202
+
203
+ def write_to(context, prefix: "".b, format_tag: @format_tag)
204
+ Core::Bytes.append(context.writable_transaction, size: size, prefix: prefix, format_tag: format_tag,
205
+ representation: :external) do |position, length|
206
+ read(position, length)
207
+ end
208
+ end
209
+
210
+ private
211
+
212
+ def each_chunk
213
+ position = 0
214
+ while position < size
215
+ chunk = read(position, [size - position, Core::Bytes::CHUNK_SIZE].min)
216
+ yield chunk
217
+ position += chunk.bytesize
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end